78 Commits

Author SHA1 Message Date
6cb64d5f03 Merge branch 'feat-sql' into ml2 2025-08-04 21:26:40 +03:00
d7d18f1284 fix: corrected redis logic to prevent temporary lock-outs on failed database transactions;
fix: ChangePassword transaction isolation;
chore: highlighted issues
2025-08-04 21:17:06 +03:00
b1125d3f6a feat: implement wish list and wish features including creation, retrieval, and updates;
fix: modify ban logic to respect expiration timestamps and pardon flags;
refactor: change boolean fields to non-nullable in models and use COALESCE for optional updates in SQL
2025-08-04 20:26:51 +03:00
3bcd8af100 Merge pull request 'Backend: finishing the first milestone' (#6) from feat-profile_service into main
Reviewed-on: #6
2025-08-03 00:25:04 +03:00
b24ffcf3f8 feat: birthday validation integrated into profile 2025-08-03 00:22:01 +03:00
3a63a14c4d refactor: implemented privacy checks in the GetProfileByUsername method;
refactor: reworked sql request for privacy-checking profile getter
2025-08-02 23:37:16 +03:00
5ed75c350a feat: remove authentication requirement for avatar and image upload endpoints;
fix: remove 500 error responses from upload endpoints;
fix: return validation error strings instead of error lists;
fix: handle invalid avatar upload IDs with 400 Bad Request response;
fix: add missing S3Controller to controller initialization;
fix: change avatar_upload_id to string type and update validation rules;
chore: add license header to smtp.go;
refactor: replace manual proxy implementation with httputil.ReverseProxy;
fix: inject S3Service dependency into ProfileService;
fix: set color and color_grad fields during profile update;
fix: correct DTO mapping for profile and settings;
fix: check object existence before copying in SaveUpload;
fix: adjust profile DTO mapping function for proper pointer handling
2025-08-02 03:47:56 +03:00
669349e020 chore: remove direct avatar upload endpoint (POST /profile/avatar);
feat: add endpoints for presigned upload URLs (GET /upload/avatar, GET /upload/image);
refactor: replace ProfileDto with NewProfileDto in update profile endpoint;
feat: implement S3 integration for avatar management;
fix: update database queries to handle new avatar upload flow;
chore: add new dependencies for S3 handling (golang.org/x/time);
refactor: rename UploadService to S3Service;
refactor: change return type for func LocalizeS3Url(originalURL string) (*url.URL, error);
feat: add custom validator for upload_id
2025-08-01 04:34:06 +03:00
8dba0f79aa feat: UploadService controller with ratelimit middleware 2025-07-31 18:10:45 +03:00
08b3942d35 feat: UploadService for acquiring temporary file upload urls for different objects (currently avatars and general images) 2025-07-31 17:46:47 +03:00
0a38267cb0 feat: configuration parameters for minio host, port, timeout;
refactor: renamed buckets;
fix: corrected Host header changing behavior in minio gin endpoint;
feat: function to convert local minio url to /s3/ path url with the backend host
2025-07-30 14:26:27 +03:00
ed044590a0 feat: setup direct access to minio endpoint to images and avatars buckets through /s3/ path 2025-07-29 20:57:36 +03:00
e15ee90a62 feat: Automatic creation of buckets and setting expiration rules 2025-07-28 01:41:01 +03:00
fbe73e2a68 feat: bucket precreation for minioclient 2025-07-27 16:14:12 +03:00
2809e1bd37 feat: enable minio ftp 2025-07-26 22:23:43 +03:00
f08cb639a5 feat: added minio dependency 2025-07-23 21:11:49 +03:00
d14f90d628 feat: complete profile update and settings management
refactor: change profile update endpoints to PUT
refactor: changed profile settings update query to use username
chore: update SQL queries for profile operations
2025-07-23 17:46:59 +03:00
f38af13dc1 feat: GetProfileByUsername implemented 2025-07-21 23:16:09 +03:00
705b420b9e feat: implemented own profile getter;
experiment: using custom automapper function to map profile to profileDto;
refactor: adjusted ProfileService to use pointer return types with models
2025-07-20 21:39:21 +03:00
df54829a67 fix: change avatar upload response to JSON object with URL;
feat: add UrlDto for standardized URL responses;
refactor: update avatar upload endpoint to return UrlDto;
docs: regenerate Swagger;
chore: add comments for untested profile controller methods
2025-07-19 23:23:56 +03:00
f65439fb50 feat: fully implement profile controller;
feat: implement file upload handling in controller with size and type validation;
feat: add custom validation rules for bio and color hex fields;
refactor: enhance request handling with dedicated client info extraction;
chore: update profile DTOs with validation tags;
docs: profile controller swagger
2025-07-19 22:57:44 +03:00
fc0c73aa5b feat: added go-automapper for mapping dtos;
feat: implemented mapspecial package for mapping dtos that are not possible to automap by default;
initialized profile service;
added dtos for profile and profileSettings
2025-07-19 11:44:15 +03:00
6f7d8bf244 refactor: updated profiles 2025-07-18 21:08:18 +03:00
6588190e8b refactor: renamed claims file;
chore: removed more unused stuff
2025-07-18 00:10:52 +03:00
8f04566b5a chore: regenerated swagger;
chore: removed deprecated and unused stuff
2025-07-17 23:15:14 +03:00
f2274f6c58 Merge pull request 'refactor-controllers' (#5) from refactor-controllers into main
Reviewed-on: #5
2025-07-17 22:39:19 +03:00
feb0524d39 Merge pull request 'refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func;' (#4) from fix-swaggo into refactor-controllers
Reviewed-on: #4
2025-07-17 22:38:34 +03:00
f2753e1495 refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func;
fix: swagger docs work now;
chore: remove incomplete account and profile controllers;
fix: correct client info type in request middleware
2025-07-17 22:37:07 +03:00
d6e2d02bff refactor: transitioned auth controller to use the new controller structure;
feat: setup DI for controllers;
refactor: marked old utils and routes package parts as deprecated
2025-07-17 21:42:47 +03:00
f9d7439def fix: Setup interface mismatch;
refactor: GetRequest now panics on missing client_info since it is only supposed to be used on handlers behind AuthMiddleware
2025-07-17 17:53:07 +03:00
7298ab662f experiment: prototyping new ASP.NET-like controllers;
feat: ControllerMethod struct for storing data about an individual API endpoint;
feat: controllerImpl struct for setting up a controller;
feat: GetRequest method for parsing and validating a request with automatic abortion on binding/validation errors
2025-07-17 17:20:48 +03:00
ec56f64420 fix: wrong role 'guest' instead of 'user' defaulting in schema 2025-07-17 04:35:34 +03:00
249bbe4a98 feat: add user role support to database and queries;
fix: add max length validation for refresh token in RefreshRequest;
refactor: use named constants for cache durations in AuthService;
refactor: select all user columns in GetValidUserByLoginCredentials query;
2025-07-17 04:31:25 +03:00
b986d45d82 fix: handle large terminated sessions caching with pagination to prevent RAM overflow;
feat: add paginated query for terminated sessions GUIDs with limit and offset;
refactor: batch processing terminated sessions in Redis with pipeline;
chore: log batch caching progress for terminated sessions;
fix: set TTL for session termination cache keys (8 hours);
refactor: update SQL query for terminated sessions to use pagination;
fix: correct loop structure in auth service initialization
2025-07-17 04:09:15 +03:00
827928178e feat: add change password endpoint using old password;
feat: implement change password service method with validation;
fix: correct ErrorIsOneOf function logic to return true on match;
refactor: rename 'log_out_accounts' to 'log_out_sessions' for clarity;
refactor: update session termination to return GUIDs and cache in Redis;
fix: ensure RollbackOnError only rolls back uncommitted transactions;
fix: handle transaction commit errors properly in dbHelper;
refactor: add helper methods for session termination and registration;
refactor: pass client info to login and registration complete methods;
fix: improve token validation error handling in refresh endpoint;
refactor: update auth middleware to set session info correctly;
chore: remove unused ClientInfo DTO;
fix: correct password reset complete to use session termination helper;
refactor: adjust database queries for session management;
chore: update SQL schema and queries for sessions;
docs: update swagger docs with new endpoint and model changes
2025-07-17 03:44:22 +03:00
8b558eaf5f feat: fully implemented Refresh method;
fix: Improve error handling in Refresh method for token validation;
fix: Update Refresh route to use correct request model;
fix: Correct request model for password reset complete route;
fix: Redis pipeline error handling in AuthService constructor;
fix: Refresh method wanted access token;
refactor: Enhance error handling for unexpected token validation errors;
refactor: Simplify claims extraction in ValidateToken method;
fix: Ensure session termination state is correctly dereferenced;
refactor: Return structured session info in ValidateToken method;
feat: New util method to check if an error is one of multiple given ones;
2025-07-15 23:32:25 +03:00
e465da6854 refactor: Simplify AuthMiddleware;
refactor: Move token validation logic to AuthService;
refactor: Remove Redis cache checks from middleware;
fix: Improve error handling for token validation;
refactor: Update Refresh method to use new validation logic;
chore: Clean up unused imports and comments
2025-07-15 22:37:41 +03:00
a582b75c82 feat: new ValidateToken method for AuthService, based on code from the monolithic implementation of auth middleware;
feat: add detailed authentication error types;
2025-07-15 21:59:05 +03:00
b3a405016e refactor: introduce DTOs for claims, session, and request handling
feat: add token validation service method
refactor: update middleware to use structured DTOs
feat: implement session info propagation through context
refactor: replace ad-hoc structs with DTOs in middleware
chore: organize auth-related data structures
2025-07-15 20:54:12 +03:00
ee6cff4104 feat: add registration attempt rate limiting with Redis
feat: prevent email enumeration by caching registration state
fix: correct Redis key formatting for session termination cache
refactor: improve registration flow with Redis cooldown checks
chore: add Redis caching for registration in-progress state
2025-07-15 02:55:26 +03:00
d8ea9f79c6 feat: add session expiration tracking and validation
feat: implement Redis caching for terminated sessions
feat: add new session GUID queries for validation
refactor: extend Session model with last_refresh_exp_time
refactor: update token generation to include role and session
refactor: modify auth middleware to validate session status
refactor: replace GetUserSessions with GetValidUserSessions
chore: add uuid/v5 dependency
fix: update router to pass dependencies to auth middleware
chore: update SQL schema and queries for new expiration field
2025-07-14 20:44:30 +03:00
24cb8ecb6e feat: implemented controller methods for passwordresetcomplete, refresh in auth controller 2025-07-13 20:58:36 +03:00
95294686b7 feat: PasswordResetBegin of auth controller;
fix: sql query updateLoginInformationByUsername used in-database hashing;
refactor: renamed LogOutAccounts into LogOutSessions in models/auth;
refactor: added error checks on opening transactions for all auth service methods;
refactor: added error checks on commiting transactions likewise;
refactor: simplified PasswordResetBegin logic;
feat: implemented PasswordResetComplete method of auth service;
2025-07-13 19:10:34 +03:00
65ea47dbb6 feat: new RollbackOnError method added for transactional db helper and integrated into auth service 2025-07-13 15:57:34 +03:00
a3bebd89be chore: todo comments;
fix: txless creation of confirmation code in passwordresetbegin
2025-07-13 02:18:46 +03:00
a2dd8993a6 feat: auth service logic for purging expired unverified accounts upon registration, new sql queries for this purpose 2025-07-13 01:57:19 +03:00
8fa57eddb1 feat: implemented PasswordResetBegin method in auth service with cooldown for each email being stored in redis 2025-07-12 19:32:53 +03:00
b91ff2c802 refactor: added redisclient connection error check 2025-07-12 17:04:09 +03:00
541847221b chore: tidy swagger comments;
feat: password reset models;
feat: verification code validator
2025-07-11 17:44:48 +03:00
c988a16783 refactor: removed error logs from smtp service since they are redundant 2025-07-10 12:21:23 +03:00
f59b647b27 feat: development compose file;
fix: smtp service
2025-07-10 01:51:48 +03:00
15c140db31 feat: implemented smtp service;
feat: implemented registration emails;
fix: config variables for password length used the same env variable;
refactor: all available config variables added to docker-compose.yml
2025-07-09 23:26:30 +03:00
63b63038d1 feat: initialized smtp service;
refactor: config now returns a copy of a struct to prevent editing;
chore: corrected identation
2025-07-08 23:21:00 +03:00
b5fdcd5dca feat: smtp config;
chore: added license comment blocks to the rest of backend and to sqlc schema & queries
2025-07-07 01:31:21 +03:00
72a512bb4f feat: automatic termination of older sessions on login (temporary until release 4);
fix: login controller method not returning tokens
2025-07-06 14:45:36 +03:00
8588a17928 chore: updated docs 2025-07-06 14:03:55 +03:00
bc9f5c6d3c fix: unique user id in user session;
feat: login controller method;
fix: name validation hander
2025-07-06 14:00:59 +03:00
333817c9e1 refactor: moved hashing logic into application layer for security;
fix: error handling in auth service for database;
refactor: removed redundant taken email check;
chore: removed todos that were completed/not needed;
fix: leaking transactions in complete registration and login on error;
refactor: got rid of txless requests during transactions;
2025-07-06 13:01:08 +03:00
5e32c3cbd3 refactor: password requirements variables;
refactor: password validation function moved to custom validators;
refactor: adjusted model's validation fields
2025-07-05 17:50:01 +03:00
8319afc7ea refactor/fix: now using pgx errors for postgres error checking instead of trying to look up the error code;
feat: implemented working custom validation;
fix: authservice begin/complete registration
2025-07-05 03:08:00 +03:00
0a51727af8 refactor: updated swagger;
feat: helper function in errors for checking postgres error types;
feat: sql query method for finding users by their email;
feat: registration begin/complete with checking existing username/email;
refactor: error handling in controller
2025-07-03 04:33:25 +03:00
d08db300fc fix: getconfirmationcode query 2025-07-02 14:09:10 +03:00
96e41efdec feat: added session guid and token type fields to jwt tokens;
feat: very minimal implementation of registration functions;
refactor: login function now uses the transactional db helper function and creates a session;
feat: enum for jwt token type
2025-07-01 14:18:01 +03:00
284d959bc3 feat: new general and auth errors;
feat: NewPointer helper function in utils;
refactor: length validation in auth models
2025-06-30 01:34:59 +03:00
e2d83aa779 refactor: database update methods use coalesce to omit nil fields 2025-06-27 13:30:03 +03:00
69d55ce060 fix: added missing return statement;
refactor: redundant comments
2025-06-25 16:21:16 +03:00
cbcfb8a286 feat: middleware for request body parsing, validation and authentication;
feat: helper function for getting request info from gin context
2025-06-24 17:31:48 +03:00
c2059dcd6e feat: middlewares for authorization and automatic request parsing;
feat: roles enum
2025-06-24 13:57:39 +03:00
be9aee7145 chore: GPL-3.0 license propagated into *.go files in backend 2025-06-24 01:37:47 +03:00
cfe60cfb8e chore: corrected misleading error descriptions, removed redundant comments 2025-06-24 01:11:49 +03:00
e5d245519a feat: preparing structures for validation features;
feat: config variables for password requirements;
feat: util function for validating passwords
2025-06-24 00:25:59 +03:00
0a00a5ee2b feat: registrationBegin method without email;
fix: missing sqlc query parameter name;
feat: util for generating security codes;
feat: enums package
2025-06-23 16:23:46 +03:00
1b55498b00 refactor: a better DI-friendy logger implementation that doesn't suck 2025-06-23 14:18:25 +03:00
ea3743cb04 fixed: error handling in commit;
refactor: exposed untransactional queries for transactional db helper again but with a clearer name this time since it still may be useful
2025-06-22 12:41:22 +03:00
613deae8e2 feat: db regular and transactional helpers to reduce boilerplate 2025-06-21 20:04:20 +03:00
e1df58b434 Merge pull request 'fix: viper not unmarshalling env variables into the struct' (#3) from fix-config into main
Reviewed-on: #3
2025-06-21 16:20:46 +03:00
ad118cc832 chore: removed database url print in config 2025-06-21 16:20:20 +03:00
a9b28c860f fix: viper not unmarshalling env variables into the struct 2025-06-21 16:18:56 +03:00
67 changed files with 6630 additions and 909 deletions

View File

@@ -1,7 +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/>.
// @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
@@ -16,18 +33,22 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/zap"
"easywish/config" "easywish/config"
docs "easywish/docs" docs "easywish/docs"
"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"
swaggerfiles "github.com/swaggo/files" swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger" ginSwagger "github.com/swaggo/gin-swagger"
@@ -39,17 +60,23 @@ func main() {
panic(err) panic(err)
} }
cfg := config.GetConfig()
fx.New( fx.New(
fx.Provide( fx.Provide(
logger.NewLogger, logger.NewLogger,
logger.NewSyncLogger,
redisclient.NewRedisClient,
minioclient.NewMinioClient,
gin.Default, gin.Default,
), ),
database.Module, database.Module,
services.Module, services.Module,
controllers.Module, validation.Module,
routes.Module,
fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine) { controllers.Module,
fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) {
// Swagger // Swagger
docs.SwaggerInfo.Schemes = []string{"http"} docs.SwaggerInfo.Schemes = []string{"http"}
@@ -57,7 +84,7 @@ func main() {
// Gin // Gin
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf(":%s", config.GetConfig().Port), Addr: fmt.Sprintf(":%s", strconv.Itoa(int(cfg.Port))),
Handler: router, Handler: router,
} }
@@ -65,6 +92,7 @@ func main() {
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
go func() { go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
syncLogger.Fatal("Server failed", zap.Error(err))
} }
}() }()
return nil return nil
@@ -72,11 +100,18 @@ func main() {
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
return server.Shutdown(shutdownCtx)
if err := server.Shutdown(shutdownCtx); err != nil {
syncLogger.Error("Server shutdown error", zap.Error(err))
}
if err := syncLogger.Close(); err != nil {
syncLogger.Error("Logger sync error", zap.Error(err))
}
return nil
}, },
}) })
}), }),
).Run() ).Run()
} }

View File

@@ -1,24 +1,66 @@
// 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 config package config
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
type Config struct { type Config struct {
Hostname string `mapstructure:"HOSTNAME"` Hostname string `mapstructure:"HOSTNAME"`
Port string `mapstructure:"PORT"` Port uint16 `mapstructure:"PORT"`
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"`
JwtIssuer string `mapstructure:"JWT_ISSUER"` JwtIssuer string `mapstructure:"JWT_ISSUER"`
JwtAudience string `mapstructure:"JWT_AUDIENCE"` JwtAudience string `mapstructure:"JWT_AUDIENCE"`
JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"` JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"`
JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"` JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"`
SmtpEnabled bool `mapstructure:"SMTP_ENABLED"`
SmtpServer string `mapstructure:"SMTP_SERVER"`
SmtpPort uint16 `mapstructure:"SMTP_PORT"`
SmtpUser string `mapstructure:"SMTP_USER"`
SmtpPassword string `mapstructure:"SMTP_PASSWORD"`
SmtpFrom string `mapstructure:"SMTP_FROM"`
SmtpUseTLS bool `mapstructure:"SMTP_USE_TLS"`
SmtpUseSSL bool `mapstructure:"SMTP_USE_SSL"`
SmtpTimeout uint `mapstructure:"SMTP_TIMEOUT"`
PasswordMinLength int `mapstructure:"PASSWORD_MIN_LENGTH"`
PasswordMaxLength int `mapstructure:"PASSWORD_MAX_LENGTH"`
PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"`
PasswordCheckCharacters bool `mapstructure:"PASSWORD_CHECK_CHARACTERS"`
PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"`
PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"`
PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"`
Environment string `mapstructure:"ENVIRONMENT"` Environment string `mapstructure:"ENVIRONMENT"`
} }
@@ -26,20 +68,82 @@ 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)
viper.SetDefault("JWT_EXP_REFRESH", "10080") viper.SetDefault("JWT_EXP_REFRESH", 10080)
viper.SetDefault("JWT_AUDIENCE", "easywish") viper.SetDefault("JWT_AUDIENCE", "easywish")
viper.SetDefault("JWT_ISSUER", "easywish") viper.SetDefault("JWT_ISSUER", "easywish")
viper.SetDefault("SMTP_ENABLED", false)
viper.SetDefault("SMTP_USE_TLS", false)
viper.SetDefault("SMTP_USE_SSL", false)
viper.SetDefault("SMTP_FROM", "An Easywish instance")
viper.SetDefault("PASSWORD_MIN_LENGTH", 6)
viper.SetDefault("PASSWORD_MAX_LENGTH", 100)
viper.SetDefault("PASSWORD_CHECK_NUMBERS", false)
viper.SetDefault("PASSWORD_CHECK_CHARACTERS", false)
viper.SetDefault("PASSWORD_CHECK_CASES", false)
viper.SetDefault("PASSWORD_CHECK_SYMBOLS", false)
viper.SetDefault("PASSWORD_CHECK_LEAKED", false)
viper.SetDefault("ENVIRONMENT", "production") viper.SetDefault("ENVIRONMENT", "production")
viper.AutomaticEnv() viper.AutomaticEnv()
// Viper's AutomaticEnv() expects lowercase keys for unmarshalling into structs by default,
// while the environment variables and struct tags are in uppercase.
// Here's the stupidity we have to do to fix it:
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.BindEnv("HOSTNAME")
viper.BindEnv("PORT")
viper.BindEnv("MINIO_TIMEOUT")
viper.BindEnv("POSTGRES_URL")
viper.BindEnv("REDIS_URL")
viper.BindEnv("MINIO_URL")
viper.BindEnv("MINIO_HOST")
viper.BindEnv("MINIO_PORT")
viper.BindEnv("JWT_ALGORITHM")
viper.BindEnv("JWT_SECRET")
viper.BindEnv("JWT_ISSUER")
viper.BindEnv("JWT_AUDIENCE")
viper.BindEnv("JWT_EXP_ACCESS")
viper.BindEnv("JWT_EXP_REFRESH")
viper.BindEnv("SMTP_ENABLED")
viper.BindEnv("SMTP_SERVER")
viper.BindEnv("SMTP_PORT")
viper.BindEnv("SMTP_USER")
viper.BindEnv("SMTP_PASSWORD")
viper.BindEnv("SMTP_FROM")
viper.BindEnv("SMTP_USE_TLS")
viper.BindEnv("SMTP_USE_SSL")
viper.BindEnv("SMTP_TIMEOUT")
viper.BindEnv("PASSWORD_MIN_LENGTH")
viper.BindEnv("PASSWORD_MAX_LENGTH")
viper.BindEnv("PASSWORD_CHECK_NUMBERS")
viper.BindEnv("PASSWORD_CHECK_CHARACTERS")
viper.BindEnv("PASSWORD_CHECK_CASES")
viper.BindEnv("PASSWORD_CHECK_SYMBOLS")
viper.BindEnv("PASSWORD_CHECK_LEAKED")
viper.BindEnv("ENVIRONMENT")
required := []string{ required := []string{
"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 {
@@ -57,10 +161,11 @@ func Load() (*Config, error) {
} }
config = &cfg config = &cfg
return &cfg, nil return &cfg, nil
} }
func GetConfig() *Config { func GetConfig() Config {
if config == nil { if config == nil {
@@ -69,7 +174,7 @@ func GetConfig() *Config {
} }
} }
return config return *config
} }
var config *Config var config *Config

View File

@@ -11,15 +11,15 @@ const docTemplate = `{
"title": "{{.Title}}", "title": "{{.Title}}",
"contact": {}, "contact": {},
"license": { "license": {
"name": "GPL 3.0" "name": "GPL-3.0"
}, },
"version": "{{.Version}}" "version": "{{.Version}}"
}, },
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/account/changePassword": { "/auth/changePassword": {
"put": { "post": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -32,10 +32,28 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Account" "Auth"
], ],
"summary": "Change account password", "summary": "Set new password using the old password",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "Password successfully changed"
},
"403": {
"description": "Invalid old password"
}
}
} }
}, },
"/auth/login": { "/auth/login": {
@@ -52,7 +70,7 @@ const docTemplate = `{
"summary": "Acquire tokens via login credentials (and 2FA code if needed)", "summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -63,10 +81,13 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"200": { "200": {
"description": "desc", "description": " ",
"schema": { "schema": {
"$ref": "#/definitions/models.LoginResponse" "$ref": "#/definitions/models.LoginResponse"
} }
},
"403": {
"description": "Invalid login credentials"
} }
} }
} }
@@ -83,7 +104,25 @@ const docTemplate = `{
"Auth" "Auth"
], ],
"summary": "Request password reset email", "summary": "Request password reset email",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
} }
}, },
"/auth/passwordResetComplete": { "/auth/passwordResetComplete": {
@@ -97,8 +136,29 @@ const docTemplate = `{
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Complete password reset with email code and provide 2FA code or backup code if needed", "summary": "Complete password reset via email code",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
} }
}, },
"/auth/refresh": { "/auth/refresh": {
@@ -113,7 +173,28 @@ const docTemplate = `{
"Auth" "Auth"
], ],
"summary": "Receive new tokens via refresh token", "summary": "Receive new tokens via refresh token",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
} }
}, },
"/auth/registrationBegin": { "/auth/registrationBegin": {
@@ -130,7 +211,7 @@ const docTemplate = `{
"summary": "Register an account", "summary": "Register an account",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -139,7 +220,17 @@ const docTemplate = `{
} }
} }
], ],
"responses": {} "responses": {
"200": {
"description": "Account is created and awaiting verification"
},
"409": {
"description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
}
}
} }
}, },
"/auth/registrationComplete": { "/auth/registrationComplete": {
@@ -154,30 +245,31 @@ const docTemplate = `{
"Auth" "Auth"
], ],
"summary": "Confirm with code, finish creating the account", "summary": "Confirm with code, finish creating the account",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
},
"403": {
"description": "Invalid email or verification code"
}
}
} }
}, },
"/profile": { "/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -193,11 +285,54 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get own profile when authorized", "summary": "Get your profile",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
} }
}, },
"/profile/privacy": { "put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.NewProfileDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/settings": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -213,10 +348,17 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get profile privacy settings", "summary": "Get your profile settings",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
}, },
"patch": { "put": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -231,8 +373,26 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Update profile privacy settings", "summary": "Update your profile's settings",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
} }
}, },
"/profile/{username}": { "/profile/{username}": {
@@ -251,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": {
@@ -281,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"
} }
} }
} }
@@ -289,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": {
@@ -299,15 +609,22 @@ const docTemplate = `{
}, },
"models.LoginRequest": { "models.LoginRequest": {
"type": "object", "type": "object",
"required": [
"password",
"username"
],
"properties": { "properties": {
"password": { "password": {
"type": "string" "type": "string",
"maxLength": 100
}, },
"totp": { "totp": {
"type": "string" "type": "string"
}, },
"username": { "username": {
"type": "string" "type": "string",
"maxLength": 20,
"minLength": 3
} }
} }
}, },
@@ -322,20 +639,138 @@ const docTemplate = `{
} }
} }
}, },
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_sessions": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string",
"maxLength": 2000
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": { "models.RegistrationBeginRequest": {
"type": "object", "type": "object",
"required": [
"email",
"password",
"username"
],
"properties": { "properties": {
"email": { "email": {
"type": "string" "type": "string"
}, },
"password": { "password": {
"description": "TODO: password checking",
"type": "string" "type": "string"
}, },
"username": { "username": {
"type": "string" "type": "string"
} }
} }
},
"models.RegistrationCompleteRequest": {
"type": "object",
"required": [
"name",
"username",
"verification_code"
],
"properties": {
"birthday": {
"type": "string"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.RegistrationCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -8,14 +8,14 @@
"title": "Easywish client API", "title": "Easywish client API",
"contact": {}, "contact": {},
"license": { "license": {
"name": "GPL 3.0" "name": "GPL-3.0"
}, },
"version": "1.0" "version": "1.0"
}, },
"basePath": "/api/", "basePath": "/api/",
"paths": { "paths": {
"/account/changePassword": { "/auth/changePassword": {
"put": { "post": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -28,10 +28,28 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Account" "Auth"
], ],
"summary": "Change account password", "summary": "Set new password using the old password",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "Password successfully changed"
},
"403": {
"description": "Invalid old password"
}
}
} }
}, },
"/auth/login": { "/auth/login": {
@@ -48,7 +66,7 @@
"summary": "Acquire tokens via login credentials (and 2FA code if needed)", "summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -59,10 +77,13 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "desc", "description": " ",
"schema": { "schema": {
"$ref": "#/definitions/models.LoginResponse" "$ref": "#/definitions/models.LoginResponse"
} }
},
"403": {
"description": "Invalid login credentials"
} }
} }
} }
@@ -79,7 +100,25 @@
"Auth" "Auth"
], ],
"summary": "Request password reset email", "summary": "Request password reset email",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
} }
}, },
"/auth/passwordResetComplete": { "/auth/passwordResetComplete": {
@@ -93,8 +132,29 @@
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Complete password reset with email code and provide 2FA code or backup code if needed", "summary": "Complete password reset via email code",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
} }
}, },
"/auth/refresh": { "/auth/refresh": {
@@ -109,7 +169,28 @@
"Auth" "Auth"
], ],
"summary": "Receive new tokens via refresh token", "summary": "Receive new tokens via refresh token",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
} }
}, },
"/auth/registrationBegin": { "/auth/registrationBegin": {
@@ -126,7 +207,7 @@
"summary": "Register an account", "summary": "Register an account",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -135,7 +216,17 @@
} }
} }
], ],
"responses": {} "responses": {
"200": {
"description": "Account is created and awaiting verification"
},
"409": {
"description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
}
}
} }
}, },
"/auth/registrationComplete": { "/auth/registrationComplete": {
@@ -150,30 +241,31 @@
"Auth" "Auth"
], ],
"summary": "Confirm with code, finish creating the account", "summary": "Confirm with code, finish creating the account",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
},
"403": {
"description": "Invalid email or verification code"
}
}
} }
}, },
"/profile": { "/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -189,11 +281,54 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get own profile when authorized", "summary": "Get your profile",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
} }
}, },
"/profile/privacy": { "put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.NewProfileDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/settings": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -209,10 +344,17 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get profile privacy settings", "summary": "Get your profile settings",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
}, },
"patch": { "put": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -227,8 +369,26 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Update profile privacy settings", "summary": "Update your profile's settings",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
} }
}, },
"/profile/{username}": { "/profile/{username}": {
@@ -247,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": {
@@ -277,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"
} }
} }
} }
@@ -285,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": {
@@ -295,15 +605,22 @@
}, },
"models.LoginRequest": { "models.LoginRequest": {
"type": "object", "type": "object",
"required": [
"password",
"username"
],
"properties": { "properties": {
"password": { "password": {
"type": "string" "type": "string",
"maxLength": 100
}, },
"totp": { "totp": {
"type": "string" "type": "string"
}, },
"username": { "username": {
"type": "string" "type": "string",
"maxLength": 20,
"minLength": 3
} }
} }
}, },
@@ -318,20 +635,138 @@
} }
} }
}, },
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_sessions": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string",
"maxLength": 2000
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": { "models.RegistrationBeginRequest": {
"type": "object", "type": "object",
"required": [
"email",
"password",
"username"
],
"properties": { "properties": {
"email": { "email": {
"type": "string" "type": "string"
}, },
"password": { "password": {
"description": "TODO: password checking",
"type": "string" "type": "string"
}, },
"username": { "username": {
"type": "string" "type": "string"
} }
} }
},
"models.RegistrationCompleteRequest": {
"type": "object",
"required": [
"name",
"username",
"verification_code"
],
"properties": {
"birthday": {
"type": "string"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.RegistrationCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

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
@@ -8,11 +69,17 @@ definitions:
models.LoginRequest: models.LoginRequest:
properties: properties:
password: password:
maxLength: 100
type: string type: string
totp: totp:
type: string type: string
username: username:
maxLength: 20
minLength: 3
type: string type: string
required:
- password
- username
type: object type: object
models.LoginResponse: models.LoginResponse:
properties: properties:
@@ -21,42 +88,131 @@ definitions:
refresh_token: refresh_token:
type: string type: string
type: object type: object
models.PasswordResetBeginRequest:
properties:
email:
type: string
required:
- email
type: object
models.PasswordResetCompleteRequest:
properties:
email:
type: string
log_out_sessions:
type: boolean
password:
type: string
verification_code:
type: string
required:
- email
- password
- verification_code
type: object
models.PasswordResetCompleteResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.PresignedUploadResponse:
properties:
fields:
additionalProperties:
type: string
type: object
url:
type: string
type: object
models.RefreshRequest:
properties:
refresh_token:
maxLength: 2000
type: string
required:
- refresh_token
type: object
models.RefreshResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.RegistrationBeginRequest: models.RegistrationBeginRequest:
properties: properties:
email: email:
type: string type: string
password: password:
description: 'TODO: password checking'
type: string type: string
username: username:
type: string type: string
required:
- email
- password
- username
type: object
models.RegistrationCompleteRequest:
properties:
birthday:
type: string
name:
type: string
username:
type: string
verification_code:
type: string
required:
- name
- username
- verification_code
type: object
models.RegistrationCompleteResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object type: object
info: info:
contact: {} contact: {}
description: Easy and feature-rich wishlist. description: Easy and feature-rich wishlist.
license: license:
name: GPL 3.0 name: GPL-3.0
title: Easywish client API title: Easywish client API
version: "1.0" version: "1.0"
paths: paths:
/account/changePassword: /auth/changePassword:
put: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.ChangePasswordRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: Password successfully changed
"403":
description: Invalid old password
security: security:
- JWT: [] - JWT: []
summary: Change account password summary: Set new password using the old password
tags: tags:
- Account - Auth
/auth/login: /auth/login:
post: post:
consumes: consumes:
- application/json - application/json
parameters: parameters:
- description: desc - description: ' '
in: body in: body
name: request name: request
required: true required: true
@@ -66,9 +222,11 @@ paths:
- application/json - application/json
responses: responses:
"200": "200":
description: desc description: ' '
schema: schema:
$ref: '#/definitions/models.LoginResponse' $ref: '#/definitions/models.LoginResponse'
"403":
description: Invalid login credentials
summary: Acquire tokens via login credentials (and 2FA code if needed) summary: Acquire tokens via login credentials (and 2FA code if needed)
tags: tags:
- Auth - Auth
@@ -76,9 +234,20 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetBeginRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: Reset code sent to the email if it is attached to an account
"429":
description: Too many recent requests for this email
summary: Request password reset email summary: Request password reset email
tags: tags:
- Auth - Auth
@@ -86,20 +255,45 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetCompleteRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
summary: Complete password reset with email code and provide 2FA code or backup "200":
code if needed description: ' '
schema:
$ref: '#/definitions/models.PasswordResetCompleteResponse'
"403":
description: Wrong verification code or username
summary: Complete password reset via email code
tags: tags:
- Auth - Auth
/auth/refresh: /auth/refresh:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RefreshRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.RefreshResponse'
"401":
description: Invalid refresh token
summary: Receive new tokens via refresh token summary: Receive new tokens via refresh token
tags: tags:
- Auth - Auth
@@ -108,7 +302,7 @@ paths:
consumes: consumes:
- application/json - application/json
parameters: parameters:
- description: desc - description: ' '
in: body in: body
name: request name: request
required: true required: true
@@ -116,7 +310,13 @@ paths:
$ref: '#/definitions/models.RegistrationBeginRequest' $ref: '#/definitions/models.RegistrationBeginRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: Account is created and awaiting verification
"409":
description: Username or email is already taken
"429":
description: Too many recent registration attempts for this email
summary: Register an account summary: Register an account
tags: tags:
- Auth - Auth
@@ -124,22 +324,61 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RegistrationCompleteRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.RegistrationCompleteResponse'
"403":
description: Invalid email or verification code
summary: Confirm with code, finish creating the account summary: Confirm with code, finish creating the account
tags: tags:
- Auth - Auth
/profile: /profile:
patch: get:
consumes: consumes:
- application/json - application/json
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileDto'
security: security:
- JWT: [] - JWT: []
summary: Update profile summary: Get your profile
tags:
- Profile
put:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.NewProfileDto'
produces:
- application/json
responses:
"200":
description: ' '
schema:
type: boolean
security:
- JWT: []
summary: Update your profile
tags: tags:
- Profile - Profile
/profile/{username}: /profile/{username}:
@@ -147,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:
@@ -206,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

@@ -4,7 +4,11 @@ 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-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
@@ -12,23 +16,27 @@ 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
github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // 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/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -36,29 +44,37 @@ require (
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=
@@ -37,8 +45,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.26.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,10 @@ 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/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -60,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=
@@ -74,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=
@@ -115,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=
@@ -136,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=
@@ -145,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=
@@ -158,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=
@@ -167,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=
@@ -178,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

@@ -1,18 +0,0 @@
package controllers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// @Summary Change account password
// @Tags Account
// @Accept json
// @Produce json
// @Security JWT
// @Router /account/changePassword [put]
func ChangePassword(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}

View File

@@ -1,114 +1,299 @@
// 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 package controllers
import ( import (
errs "easywish/internal/errors"
"easywish/internal/models" "easywish/internal/models"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/utils"
"easywish/internal/utils/enums"
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"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}
return &controllerImpl{
Path: "/auth",
Middleware: []gin.HandlerFunc{},
Methods: []ControllerMethod{
{
HttpMethod: POST,
Path: "/registrationBegin",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.registrationBeginHandler,
},
{
HttpMethod: POST,
Path: "/registrationComplete",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.registrationCompleteHandler,
},
{
HttpMethod: POST,
Path: "/login",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.loginHandler,
},
{
HttpMethod: POST,
Path: "/refresh",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.refreshHandler,
},
{
HttpMethod: POST,
Path: "/passwordResetBegin",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.passwordResetBeginHandler,
},
{
HttpMethod: POST,
Path: "/passwordResetComplete",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.passwordResetCompleteHandler,
},
{
HttpMethod: POST,
Path: "/changePassword",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.changePasswordHandler,
},
},
}
} }
func NewAuthController(as services.AuthService) AuthController {
return &authControllerImpl{authService: as}
}
// Login implements AuthController.
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true "desc"
// @Success 200 {object} models.LoginResponse "desc"
// @Router /auth/login [post]
func (a *authControllerImpl) Login(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// PasswordResetBegin implements AuthController.
// @Summary Request password reset email
// @Tags Auth
// @Accept json
// @Produce json
// @Router /auth/passwordResetBegin [post]
func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// PasswordResetComplete implements AuthController.
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed
// @Tags Auth
// @Accept json
// @Produce json
// @Router /auth/passwordResetComplete [post]
func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// Refresh implements AuthController.
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Router /auth/refresh [post]
func (a *authControllerImpl) Refresh(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// RegistrationComplete implements AuthController.
// @Summary Register an account // @Summary Register an account
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.RegistrationBeginRequest true "desc" // @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification"
// @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post] // @Router /auth/registrationBegin [post]
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { func (ctrl *AuthController) registrationBeginHandler(c *gin.Context) {
request, err := GetRequest[models.RegistrationBeginRequest](c)
var request models.RegistrationBeginRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.Status(http.StatusBadRequest)
return
}
_, err := a.authService.RegistrationBegin(request)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, err.Error())
return return
} }
c.Status(http.StatusAccepted) _, err = ctrl.auth.RegistrationBegin(request.Body)
if err != nil {
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
c.Status(http.StatusConflict)
} else if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusTooManyRequests)
} else {
c.Status(http.StatusInternalServerError)
}
return return
} }
// RegistrationBegin implements AuthController. c.Status(http.StatusOK)
}
// @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 " "
// @Success 200 {object} models.RegistrationCompleteResponse " "
// @Failure 403 "Invalid email or verification code"
// @Router /auth/registrationComplete [post] // @Router /auth/registrationComplete [post]
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { func (ctrl *AuthController) registrationCompleteHandler(c *gin.Context) {
c.Status(http.StatusNotImplemented) request, err := GetRequest[models.RegistrationCompleteRequest](c)
if err != nil {
return
} }
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { response, err := ctrl.auth.RegistrationComplete(request.User, request.Body)
group.POST("/registrationBegin", a.RegistrationBegin) if err != nil {
group.POST("/registrationComplete", a.RegistrationComplete) if errors.Is(err, errs.ErrForbidden) {
group.POST("/login", a.Login) c.Status(http.StatusForbidden)
group.POST("/refresh", a.Refresh) } else if errors.Is(err, errs.ErrUnauthorized) {
group.POST("/passwordResetBegin", a.PasswordResetBegin) c.Status(http.StatusUnauthorized)
group.POST("/passwordResetComplete", a.PasswordResetComplete) } else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true " "
// @Success 200 {object} models.LoginResponse " "
// @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,85 +1,197 @@
// 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 package controllers
import ( import (
"easywish/internal/middleware" "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 {
}
func NewProfileController() ProfileController { ctrl := ProfileController{log: _log, ps: _ps}
return &profileControllerImpl{}
}
// @Summary Get someone's profile details return &controllerImpl{
// @Tags Profile Path: "/profile",
// @Accept json Middleware: []gin.HandlerFunc{},
// @Produce json Methods: []ControllerMethod{
// @Param username path string true "Username"
// @Security JWT
// @Router /profile/{username} [get]
func (p *profileControllerImpl) GetProfile(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Get own profile when authorized
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Router /profile/me [get]
func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Update profile
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Router /profile [patch]
func (p *profileControllerImpl) UpdateProfile(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Get profile privacy settings
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Router /profile/privacy [get]
func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Update profile privacy settings
// @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) {
protected := group.Group("")
protected.Use(middleware.JWTAuthMiddleware())
{ {
protected.GET("/me", p.GetOwnProfile) HttpMethod: GET,
protected.GET("/:username", p.GetProfile) Path: "",
protected.GET("/privacy", p.GetPrivacySettings) Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getMyProfile,
},
{
HttpMethod: GET,
Path: "/:username",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getProfileByUsername,
},
{
HttpMethod: GET,
Path: "/settings",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getProfileSettings,
},
{
HttpMethod: PUT,
Path: "",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.updateProfile,
},
{
HttpMethod: PUT,
Path: "/settings",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.updateProfileSettings,
},
},
} }
} }
// @Summary Get your profile
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Success 200 {object} dto.ProfileDto " "
// @Router /profile [get]
func (ctrl *ProfileController) getMyProfile(c *gin.Context) {
cinfo := GetClientInfo(c)
response, err := ctrl.ps.GetMyProfile(cinfo); if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Get profile by username
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param username path string true " "
// @Success 200 {object} dto.ProfileDto " "
// @Failure 404 "Profile not found"
// @Failure 403 "Restricted profile"
// @Router /profile/{username} [get]
func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
cinfo := GetClientInfo(c)
username := c.Param("username"); if username == "" {
c.Status(http.StatusBadRequest)
return
}
response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil {
if errors.Is(err, errs.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
} else if errors.Is(err, errs.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 your profile settings
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Success 200 {object} dto.ProfileSettingsDto " "
// @Router /profile/settings [get]
func (ctrl *ProfileController) getProfileSettings(c *gin.Context) {
cinfo := GetClientInfo(c)
response, err := ctrl.ps.GetProfileSettings(cinfo); if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Update your profile
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param request body dto.NewProfileDto true " "
// @Success 200 {object} bool " "
// @Router /profile [put]
func (ctrl *ProfileController) updateProfile(c *gin.Context) {
request, err := GetRequest[dto.NewProfileDto](c); if err != nil {
return
}
response, err := ctrl.ps.UpdateProfile(request.User, request.Body); if err != nil {
if errors.Is(err, errs.ErrFileNotFound) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload ID. Make sure file was uploaded and is not expired."})
}
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Update your profile's settings
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param request body dto.ProfileSettingsDto true " "
// @Success 200 {object} bool " "
// @Router /profile/settings [put]
func (ctrl *ProfileController) updateProfileSettings(c *gin.Context) {
request, err := GetRequest[dto.ProfileSettingsDto](c); if err != nil {
return
}
response, err := ctrl.ps.UpdateProfileSettings(request.User, request.Body); if err != nil || !response {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}

View File

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

View File

@@ -1,13 +1,74 @@
// 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 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,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 database package database
import ( import (

View File

@@ -0,0 +1,104 @@
// 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 database
import (
"context"
"github.com/jackc/pgx/v5"
)
type DbHelperTransaction interface {
Commit() error
Rollback() error
RollbackOnError(err error) error
}
type DbHelper struct {
CTX context.Context
Queries Queries
}
type dbHelperTransactionImpl struct {
CTX context.Context
TXlessQueries Queries
TX pgx.Tx
TXQueries Queries
isCommited bool
}
func NewDbHelper(dbContext DbContext) DbHelper {
ctx := context.Background()
queries := New(dbContext)
return DbHelper{
CTX: ctx,
Queries: *queries,
}
}
func NewDbHelperTransaction(dbContext DbContext) (DbHelperTransaction, *dbHelperTransactionImpl, error) {
ctx := context.Background()
queries := New(dbContext)
tx, err := dbContext.BeginTx(ctx)
if err != nil {
return nil, nil, err
}
txQueries := queries.WithTx(tx)
obj := &dbHelperTransactionImpl{
CTX: ctx,
TXlessQueries: *queries,
TX: tx,
TXQueries: *txQueries,
}
return obj, obj, nil
}
// Commit implements DbHelperTransaction.
func (d *dbHelperTransactionImpl) Commit() error {
errCommit := d.TX.Commit(d.CTX)
if errCommit != nil {
d.isCommited = true
}
return errCommit
}
// Rollback implements DbHelperTransaction.
func (d *dbHelperTransactionImpl) Rollback() error {
if d.isCommited {
return nil
}
return d.TX.Rollback(d.CTX);
}
// RollbackOnError implements DbHelperTransaction.
func (d *dbHelperTransactionImpl) RollbackOnError(err error) error {
if d.isCommited || err == nil {
return d.Rollback()
}
return nil
}

View File

@@ -12,11 +12,11 @@ type BannedUser struct {
ID int64 ID int64
UserID int64 UserID int64
Date pgtype.Timestamp Date pgtype.Timestamp
Reason pgtype.Text Reason *string
ExpiresAt pgtype.Timestamp ExpiresAt pgtype.Timestamp
BannedBy pgtype.Text BannedBy *string
Pardoned pgtype.Bool Pardoned bool
PardonedBy pgtype.Text PardonedBy *string
} }
type ConfirmationCode struct { type ConfirmationCode struct {
@@ -25,17 +25,17 @@ type ConfirmationCode struct {
CodeType int32 CodeType int32
CodeHash string CodeHash string
ExpiresAt pgtype.Timestamp ExpiresAt pgtype.Timestamp
Used pgtype.Bool Used bool
Deleted pgtype.Bool Deleted bool
} }
type LoginInformation struct { type LoginInformation struct {
ID int64 ID int64
UserID int64 UserID int64
Email pgtype.Text Email *string
PasswordHash string PasswordHash string
TotpEncrypted pgtype.Text TotpEncrypted *string
Email2faEnabled pgtype.Bool Email2faEnabled *bool
PasswordChangeDate pgtype.Timestamp PasswordChangeDate pgtype.Timestamp
} }
@@ -43,41 +43,69 @@ type Profile struct {
ID int64 ID int64
UserID int64 UserID int64
Name string Name string
Bio pgtype.Text Bio string
AvatarUrl pgtype.Text AvatarUrl string
Birthday pgtype.Timestamp Birthday pgtype.Timestamp
Color pgtype.Text Color string
ColorGrad pgtype.Text ColorGrad string
} }
type ProfileSetting struct { type ProfileSetting struct {
ID int64 ID int64
ProfileID int64 ProfileID int64
HideFulfilled pgtype.Bool HideFulfilled bool
HideProfileDetails pgtype.Bool HideProfileDetails bool
HideForUnauthenticated pgtype.Bool HideForUnauthenticated bool
HideBirthday pgtype.Bool HideBirthday bool
HideDates pgtype.Bool HideDates bool
Captcha pgtype.Bool Captcha bool
FollowersOnlyInteraction pgtype.Bool FollowersOnlyInteraction bool
} }
type Session struct { type Session struct {
ID int64 ID int64
UserID int64 UserID int64
Guid pgtype.UUID Guid pgtype.UUID
Name pgtype.Text Name *string
Platform pgtype.Text Platform *string
LatestIp pgtype.Text LatestIp *string
LoginTime pgtype.Timestamp LoginTime pgtype.Timestamp
LastRefreshExpTime pgtype.Timestamp
LastSeenDate pgtype.Timestamp LastSeenDate pgtype.Timestamp
Terminated pgtype.Bool Terminated *bool
} }
type User struct { type User struct {
ID int64 ID int64
Username string Username string
Verified pgtype.Bool Verified bool
RegistrationDate pgtype.Timestamp RegistrationDate pgtype.Timestamp
Deleted pgtype.Bool Role int32
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

@@ -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 database package database
import ( import (
@@ -9,5 +26,3 @@ var Module = fx.Module("database",
NewDbContext, NewDbContext,
), ),
) )

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

@@ -0,0 +1,32 @@
// 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"
"github.com/golang-jwt/jwt/v5"
)
type UserClaims struct {
Username string `json:"username"`
Role enums.Role `json:"role"`
Type enums.JwtTokenType `json:"type"`
Session string `json:"session"`
jwt.RegisteredClaims
}

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 (
@@ -7,7 +24,15 @@ import (
var ( var (
ErrUnauthorized = errors.New("User is not authorized") ErrUnauthorized = errors.New("User is not authorized")
ErrUsernameTaken = errors.New("Provided username is already in use") ErrUsernameTaken = errors.New("Provided username is already in use")
ErrEmailTaken = errors.New("Provided email is already in use")
ErrUserNotFound = errors.New("User was not found")
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

@@ -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 errors
import "errors"
var (
ErrClientInfoNotProvided = errors.New("No client info provded")
)

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 (
@@ -7,5 +24,7 @@ import (
var ( 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")
ErrTooManyRequests = errors.New("Too many requests")
ErrNotFound = errors.New("Resource not found")
) )

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 errors
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
func GetPgError(err error) string {
var pgErr *pgconn.PgError
errors.As(err, &pgErr)
return pgErr.Code
}
func MatchPgError(err error, code string) bool {
return GetPgError(err) == code
}

View File

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

View File

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

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 logger package logger
import ( import (
@@ -8,40 +25,36 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
type Logger interface {
Get() *zap.Logger
Sync() error
}
type loggerImpl struct {
}
func NewLogger() Logger {
return &loggerImpl{}
}
var ( var (
logger *zap.Logger instance *zap.Logger
once sync.Once once sync.Once
) )
func (l *loggerImpl) Get() *zap.Logger { func NewLogger() *zap.Logger {
once.Do(func() { once.Do(func() {
var err error
cfg := config.GetConfig() cfg := config.GetConfig()
var err error
if cfg.Environment == "production" { if cfg.Environment == "production" {
logger, err = zap.NewProduction() instance, err = zap.NewProduction()
} else { } else {
logger, err = zap.NewDevelopment() instance, err = zap.NewDevelopment()
} }
if err != nil { if err != nil {
panic(err) panic("failed to initialize logger: " + err.Error())
} }
}) })
return logger return instance
} }
func (l *loggerImpl) Sync() error { type SyncLogger struct {
return logger.Sync() *zap.Logger
}
func NewSyncLogger(logger *zap.Logger) *SyncLogger {
return &SyncLogger{logger}
}
func (s *SyncLogger) Close() error {
return s.Sync()
} }

View File

@@ -1,56 +1,70 @@
// 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 package middleware
import ( import (
"easywish/config" "easywish/internal/dto"
"easywish/internal/services"
"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"`
jwt.RegisteredClaims
}
func JWTAuthMiddleware() 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.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Set("session_info", dto.SessionInfo{
Username: "",
Session: "",
Role: enums.GuestRole},
)
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("userID", claims.Username)
c.Next()
} else {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid claims"})
}
} }
} }

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc {
limiter := rate.NewLimiter(r, b)
return func(c *gin.Context) {
if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package minioclient
import (
"context"
"fmt"
"slices"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/lifecycle"
)
var Buckets map[string]string
func setupBuckets(client *minio.Client) {
Buckets = map[string]string{
"avatars": "ew-avatars",
"images": "ew-images",
"uploads": "ew-uploads",
}
hiddenBuckets := []string{
"uploads",
}
// NOTICE: it has a formatting value in there for the bucket name!!
// I'm kind of ashamed for doing this, but the library did not have
// an API for configuring a policy, so we're left with JSON I guess
readOnlyPolicyTemplate := `{"Version": "2012-10-17","Statement": [{"Action": ["s3:GetObject"],"Effect": "Allow","Principal": {"AWS": ["*"]},"Resource": ["arn:aws:s3:::%s/*"],"Sid": ""}]}`
ctx := context.Background()
var newBuckets []string
for key, value := range Buckets {
bucketExists, err := client.BucketExists(ctx, value); if err != nil {
panic("Failure to check if bucket '" + value + "' exists: " + err.Error())
}
if !bucketExists {
err := client.MakeBucket(ctx, value, minio.MakeBucketOptions{}); if err != nil {
panic("Failure to create bucket '" + value + "': " + err.Error())
}
newBuckets = append(newBuckets, key)
if !slices.Contains(hiddenBuckets, key) {
client.SetBucketPolicy(ctx, value, fmt.Sprintf(readOnlyPolicyTemplate, value))
}
}
}
if slices.Contains(newBuckets, "uploads") {
uploadsCfg := lifecycle.NewConfiguration()
uploadsCfg.Rules = []lifecycle.Rule{
{
ID: "expire-uploads",
Status: "Enabled",
Expiration: lifecycle.Expiration{Days: 1},
},
}
err := client.SetBucketLifecycle(ctx, Buckets["uploads"], uploadsCfg); if err != nil {
errRm := client.RemoveBucket(ctx, Buckets["uploads"])
if errRm != nil {
panic("Failed to set lifecycle configuration for the uploads bucket: '" + err.Error() + "' and then failed to delete it: " + errRm.Error())
}
panic("Failed to set lifecycle configuration for the uploads bucket: " + err.Error())
}
}
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package minioclient
import (
"easywish/config"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func setupGinEndpoint(router *gin.Engine) {
cfg := config.GetConfig()
minioHost := fmt.Sprintf("%s:%d", cfg.MinioHost, cfg.MinioPort)
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/s3")
targetURL := &url.URL{
Scheme: "http",
Host: minioHost,
Path: path,
RawQuery: req.URL.RawQuery,
}
req.URL = targetURL
req.Host = minioHost
req.Header.Set("Host", minioHost)
req.Header.Del("X-Forwarded-For")
req.Header.Del("X-Forwarded-Host")
req.Header.Del("X-Forwarded-Proto")
},
Transport: &http.Transport{
ResponseHeaderTimeout: time.Duration(cfg.MinioTimeout),
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Failed to forward request"}`))
fmt.Printf("Proxy error: %v\n", err)
},
}
s3group := router.Group("/s3")
s3group.Any("/*path", func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
})
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package minioclient
import (
"easywish/config"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func NewMinioClient(router *gin.Engine) *minio.Client {
cfg := config.GetConfig()
if cfg.MinioUrl == "" {
panic("Failed Minio URL not set in config")
}
minioURL, err := url.Parse(cfg.MinioUrl); if err != nil {
panic("Failed to parse Minio URL: " + err.Error())
}
user := minioURL.User.Username()
password, ok := minioURL.User.Password(); if !ok {
panic("Failed to parse Minio secret key from the URL")
}
client, err := minio.New(minioURL.Host, &minio.Options{
Creds: credentials.NewStaticV4(user, password, ""),
Secure: minioURL.Scheme == "https",
}); if err != nil {
panic("Failed to initiate minio client: " + err.Error())
}
_, err = client.HealthCheck(time.Second*5); if err != nil {
panic("Failed to communicate with minio: " + err.Error())
}
setupBuckets(client)
setupGinEndpoint(router)
return client
}

View File

@@ -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 models package models
type Tokens struct { type Tokens struct {
@@ -6,17 +23,16 @@ type Tokens struct {
} }
type RegistrationBeginRequest struct { type RegistrationBeginRequest struct {
Username string `json:"username"` Username string `json:"username" binding:"required" validate:"username"`
Email *string `json:"email"` Email string `json:"email" binding:"required,email"`
Password string `json:"password"` // TODO: password checking Password string `json:"password" binding:"required" validate:"password"`
} }
type RegistrationCompleteRequest struct { type RegistrationCompleteRequest struct {
Username string `json:"username"` Username string `json:"username" binding:"required" validate:"username"`
VerificationCode string `json:"verification_code"` VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"`
Name string `json:"name"` Name string `json:"name" binding:"required" validate:"name"`
Birthday *string `json:"birthday"` Birthday int64 `json:"birthday" validate:"birthday_unix_milli"`
AvatarUrl *string `json:"avatar_url"`
} }
type RegistrationCompleteResponse struct { type RegistrationCompleteResponse struct {
@@ -24,8 +40,8 @@ type RegistrationCompleteResponse struct {
} }
type LoginRequest struct { type LoginRequest struct {
Username string `json:"username"` Username string `json:"username" binding:"required,min=3,max=20" validate:"username"`
Password string `json:"password"` Password string `json:"password" binding:"required,max=100"`
TOTP *string `json:"totp"` TOTP *string `json:"totp"`
} }
@@ -34,9 +50,34 @@ type LoginResponse struct {
} }
type RefreshRequest struct { type RefreshRequest struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token" binding:"required,max=2000"`
} }
type RefreshResponse struct { type RefreshResponse struct {
Tokens Tokens
} }
type PasswordResetBeginRequest struct {
Email string `json:"email" binding:"required,email"`
}
type PasswordResetCompleteRequest struct {
Email string `json:"email" binding:"required,email"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reset"`
NewPassword string `json:"password" binding:"required" validate:"password"`
LogOutSessions bool `json:"log_out_sessions"`
}
type PasswordResetCompleteResponse struct {
Tokens
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"password" binding:"required" validate:"password"`
TOTP string `json:"totp"`
}
type ChangePasswordResponse struct {
Tokens
}

View File

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

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
type PresignedUploadResponse struct {
Url string `json:"url"`
Fields map[string]string `json:"fields"`
}

View File

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

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package redisclient
import (
"context"
"easywish/config"
"github.com/go-redis/redis/v8"
)
func NewRedisClient() *redis.Client {
cfg := config.GetConfig()
options, err := redis.ParseURL(cfg.RedisUrl)
if err != nil {
panic("Failed to parse redis URL: " + err.Error())
}
client := redis.NewClient(options)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
panic("Redis connection failed: " + err.Error())
}
return client
}

View File

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

View File

@@ -1,12 +0,0 @@
package routes
import (
"go.uber.org/fx"
)
var Module = fx.Module("routes",
fx.Provide(
NewRouteGroups,
),
fx.Invoke(NewRouter),
)

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

@@ -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 services package services
import ( import (
@@ -6,7 +23,9 @@ import (
var Module = fx.Module("services", var Module = fx.Module("services",
fx.Provide( fx.Provide(
NewUploadService,
NewSmtpService,
NewAuthService, NewAuthService,
NewProfileService,
), ),
) )

View File

@@ -0,0 +1,131 @@
// 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 (
"crypto/tls"
"easywish/config"
"fmt"
"net"
"net/smtp"
"strings"
"time"
errs "easywish/internal/errors"
)
type SmtpService interface {
SendEmail(to string, subject, body string) error
}
type smtpServiceImpl struct {
}
func NewSmtpService() SmtpService {
return &smtpServiceImpl{}
}
func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
cfg := config.GetConfig()
if !cfg.SmtpEnabled {
return errs.ErrSmtpDisabled
}
if cfg.SmtpServer == "" || cfg.SmtpPort == 0 || cfg.SmtpFrom == "" {
return errs.ErrSmtpMissingConfiguration
}
toSlice := []string{to}
headers := map[string]string{
"From": cfg.SmtpFrom,
"To": strings.Join(toSlice, ", "),
"Subject": subject,
"MIME-Version": "1.0",
"Content-Type": "text/html; charset=UTF-8",
}
var sb strings.Builder
for k, v := range headers {
sb.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
sb.WriteString("\r\n" + body)
message := []byte(sb.String())
hostPort := net.JoinHostPort(cfg.SmtpServer, fmt.Sprintf("%d", cfg.SmtpPort))
var conn net.Conn
var err error
if cfg.SmtpUseSSL {
tlsConfig := &tls.Config{ServerName: cfg.SmtpServer}
conn, err = tls.Dial("tcp", hostPort, tlsConfig)
} else {
timeout := time.Duration(cfg.SmtpTimeout) * time.Second
conn, err = net.DialTimeout("tcp", hostPort, timeout)
}
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.SmtpServer)
if err != nil {
return err
}
defer client.Close()
if !cfg.SmtpUseSSL && cfg.SmtpUseTLS {
tlsConfig := &tls.Config{ServerName: cfg.SmtpServer}
if err = client.StartTLS(tlsConfig); err != nil {
return err
}
}
// Authenticate if credentials exist
if cfg.SmtpUser != "" && cfg.SmtpPassword != "" {
auth := smtp.PlainAuth("", cfg.SmtpUser, cfg.SmtpPassword, cfg.SmtpServer)
if err = client.Auth(auth); err != nil {
return err
}
}
if err = client.Mail(cfg.SmtpFrom); err != nil {
return err
}
for _, recipient := range toSlice {
if err = client.Rcpt(recipient); err != nil {
return err
}
}
// Send email body
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(message); err != nil {
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package utils
import (
"crypto/rand"
"fmt"
"io"
)
func GenerateSecure6DigitNumber() (string, error) {
maxNumber := 1000000
b := make([]byte, 4)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % maxNumber
return fmt.Sprintf("%06d", num), nil
}

View File

@@ -1,18 +0,0 @@
package utils
import (
"context"
"easywish/config"
"github.com/jackc/pgx/v5"
)
func GetDbConn() (*pgx.Conn, context.Context, error) {
ctx := context.Background()
conn, err := pgx.Connect(ctx, config.GetConfig().DatabaseUrl)
if err != nil {
return nil, nil, err
}
return conn, ctx, nil
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package enums
type ConfirmationCodeType int32
const (
RegistrationCodeType ConfirmationCodeType = iota
PasswordResetCodeType
)
type Role int32
const (
GuestRole Role = iota
UserRole
AdminRole
)
type JwtTokenType int32
const (
JwtAccessTokenType JwtTokenType = iota
JwtRefreshTokenType
)

View File

@@ -0,0 +1,29 @@
// 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 "errors"
func ErrorIsOneOf(err error, ignoreErrors ...error) bool {
for _, ignore := range ignoreErrors {
if errors.Is(err, ignore) {
return true
}
}
return false
}

View File

@@ -0,0 +1,31 @@
// 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 "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@@ -1,23 +1,47 @@
// 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 package utils
import ( import (
"easywish/config" "easywish/config"
"easywish/internal/utils/enums"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
func GenerateTokens(username 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,
"role": role,
"session": sessionGuid,
"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,
"role": role,
"session": sessionGuid,
"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

@@ -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 utils
func NewPointer[T any](d T) *T {
return &d
}

View File

@@ -0,0 +1,148 @@
// 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 validation
import (
"easywish/config"
"fmt"
"regexp"
"time"
"github.com/go-playground/validator/v10"
)
type CustomValidatorHandler struct {
Function func(fl validator.FieldLevel) bool
FieldName string
}
func GetCustomHandlers() []CustomValidatorHandler {
handlers := []CustomValidatorHandler{
{
FieldName: "username",
Function: func(fl validator.FieldLevel) bool {
username := fl.Field().String()
return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username)
}},
{
FieldName: "name",
Function: func(fl validator.FieldLevel) bool {
username := fl.Field().String()
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",
Function: func(fl validator.FieldLevel) bool {
password := fl.Field().String()
cfg := config.GetConfig()
if cfg.PasswordMaxLength < len(password) || len(password) < cfg.PasswordMinLength {
return false
}
if cfg.PasswordCheckNumbers && !regexp.MustCompile(`\d+`).MatchString(password) {
return false
}
if cfg.PasswordCheckCases && !regexp.MustCompile(`^(?=.*[a-z])(?=.*[A-Z]).*$`).MatchString(password) ||
cfg.PasswordCheckCharacters && !regexp.MustCompile(`[a-zA-Z]`).MatchString(password) {
return false
}
if cfg.PasswordCheckSymbols && !regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password) {
return false
}
if cfg.PasswordCheckLeaked {
// TODO: implement rockme check
}
return true
}},
{
FieldName: "verification_code",
Function: func(fl validator.FieldLevel) bool {
codeType := fl.Param()
code := fl.Field().String()
if codeType == "reg" {
return regexp.MustCompile(`[\d]{6,6}`).MatchString(code)
}
if codeType == "reset" {
return regexp.MustCompile(
`^[{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?$`,
).MatchString(code)
}
panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType))
}},
{
FieldName: "upload_id",
Function: func(fl validator.FieldLevel) bool {
uploadType := fl.Param()
uploadID := fl.Field().String()
pattern := fmt.Sprintf(
"^%s-([{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?)$",
uploadType,
)
return regexp.MustCompile(pattern).MatchString(uploadID)
}},
}
return handlers
}

View File

@@ -0,0 +1,28 @@
// 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 validation
import (
"go.uber.org/fx"
)
var Module = fx.Module("validation",
fx.Provide(
NewValidator,
),
)

View File

@@ -0,0 +1,32 @@
// 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 validation
import (
"github.com/go-playground/validator/v10"
)
func NewValidator() *validator.Validate {
v := validator.New()
for _, handler := range GetCustomHandlers() {
v.RegisterValidation(handler.FieldName, handler.Function)
}
return v
}

131
dev-compose.yml Normal file
View File

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

View File

@@ -16,9 +16,36 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
environment: environment:
ENVIRONMENT: ${ENVIRONMENT}
HOSTNAME: ${HOSTNAME}
PORT: ${PORT}
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_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER}
JWT_AUDIENCE: ${JWT_AUDIENCE}
JWT_EXP_ACCESS: ${JWT_EXP_ACCESS}
JWT_EXP_REFRESH: ${JWT_EXP_REFRESH}
SMTP_ENABLED: ${SMTP_ENABLED}
SMTP_SERVER: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
SMTP_USE_TLS: ${SMTP_USE_TLS}
SMTP_USE_SSL: ${SMTP_USE_SSL}
SMTP_TIMEOUT: ${SMTP_TIMEOUT}
PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH}
PASSWORD_MAX_LENGTH: ${PASSWORD_MAX_LENGTH}
PASSWORD_CHECK_NUMBER: ${PASSWORD_CHECK_NUMBER}
PASSWORD_CHECK_CHARAC: ${PASSWORD_CHECK_CHARAC}
PASSWORD_CHECK_CASES: ${PASSWORD_CHECK_CASES}
PASSWORD_CHECK_SYMBOL: ${PASSWORD_CHECK_SYMBOL}
PASSWORD_CHECK_LEAKED: ${PASSWORD_CHECK_LEAKED}
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:

View File

@@ -1,5 +1,22 @@
-- vim:fileencoding=utf-8:foldmethod=marker -- vim:fileencoding=utf-8:foldmethod=marker
-- 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/>.
--: User Object {{{ --: User Object {{{
;-- name: CreateUser :one ;-- name: CreateUser :one
@@ -8,12 +25,16 @@ VALUES ($1, false) RETURNING *;
;-- name: UpdateUser :exec ;-- name: UpdateUser :exec
UPDATE users UPDATE users
SET verified = $2, deleted = $3 SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE id = $1; 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
@@ -32,22 +53,71 @@ WHERE id = $1;
SELECT * FROM users SELECT * FROM users
WHERE username = $1; WHERE username = $1;
;-- name: GetUserByLoginCredentials :one ;-- name: GetUserByEmail :one
SELECT users.* FROM users
JOIN login_informations linfo ON linfo.user_id = users.id
WHERE linfo.email = @email::text;
;-- 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 {{{
@@ -58,7 +128,12 @@ VALUES ( $1, $2, $3, $4) RETURNING *;
;-- name: UpdateBannedUser :exec ;-- name: UpdateBannedUser :exec
UPDATE banned_users UPDATE banned_users
SET reason = $2, expires_at = $3, banned_by = $4, pardoned = $5, pardoned_by = $6 SET
reason = COALESCE($2, reason),
expires_at = COALESCE($3, expires_at),
banned_by = COALESCE($4, banned_by),
pardoned = COALESCE($5, pardoned),
pardoned_by = COALESCE($6, pardoned_by)
WHERE id = $1; WHERE id = $1;
;-- name: GetUserBans :many ;-- name: GetUserBans :many
@@ -76,11 +151,16 @@ WHERE users.username = $1;
;-- name: CreateLoginInformation :one ;-- name: CreateLoginInformation :one
INSERT INTO login_informations(user_id, email, password_hash) INSERT INTO login_informations(user_id, email, password_hash)
VALUES ( $1, $2, crypt(@password::text, gen_salt('bf')) ) RETURNING *; VALUES ( $1, $2, @password_hash::text ) RETURNING *;
;-- name: UpdateLoginInformationByUsername :exec ;-- name: UpdateLoginInformationByUsername :exec
UPDATE login_informations UPDATE login_informations
SET email = $2, password_hash = crypt(@password::text, gen_salt('bf')), totp_encrypted = $4, email_2fa_enabled = $5, password_change_date = $6 SET
email = COALESCE($2, email),
password_hash = COALESCE(@password_hash::text, password_hash),
totp_encrypted = COALESCE($4, totp_encrypted),
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
password_change_date = COALESCE($6, password_change_date)
FROM users FROM users
WHERE users.username = $1 AND login_informations.user_id = users.id; WHERE users.username = $1 AND login_informations.user_id = users.id;
@@ -94,16 +174,32 @@ WHERE users.username = $1;
--: Confirmation Code Object {{{ --: Confirmation Code Object {{{
;-- name: CreateConfirmationCode :one ;-- name: CreateConfirmationCode :one
INSERT INTO confirmation_codes(user_id, code_type, code_hash, expires_at) INSERT INTO confirmation_codes(user_id, code_type, code_hash)
VALUES ($1, $2, crypt($3, gen_salt('bf')), $4) RETURNING *; VALUES ($1, $2, @code_hash) RETURNING *;
;-- name: GetConfirmationCodeByCode :one ;-- name: GetValidConfirmationCodeByCode :one
SELECT * FROM confirmation_codes SELECT * FROM confirmation_codes
WHERE user_id = $1 AND code_type = $2 AND expires_at > CURRENT_TIMESTAMP AND code_hash = crypt($3, code_hash); WHERE
user_id = $1 AND
code_type = $2 AND
expires_at > CURRENT_TIMESTAMP AND
used IS FALSE AND
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 used = $2, deleted = $3 SET
used = COALESCE($2, used),
deleted = COALESCE($3, deleted)
WHERE id = $1; WHERE id = $1;
;-- name: PruneExpiredConfirmationCodes :exec ;-- name: PruneExpiredConfirmationCodes :exec
@@ -120,12 +216,40 @@ VALUES ($1, $2, $3, $4) RETURNING *;
;-- name: UpdateSession :exec ;-- name: UpdateSession :exec
UPDATE sessions UPDATE sessions
SET name = $2, platform = $3, latest_ip = $4, login_time = $5, last_seen_date = $6, terminated = $7 SET
name = COALESCE($2, name),
platform = COALESCE($3, platform),
latest_ip = COALESCE($4, latest_ip),
login_time = COALESCE($5, login_time),
last_refresh_exp_time = COALESCE($6, last_refresh_exp_time),
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: 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
SET terminated = TRUE
FROM users
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
@@ -141,7 +265,13 @@ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *;
;-- name: UpdateProfileByUsername :exec ;-- name: UpdateProfileByUsername :exec
UPDATE profiles UPDATE profiles
SET name = $2, bio = $3, birthday = $4, avatar_url = $5, color = $6, color_grad = $7 SET
name = COALESCE($2, name),
bio = COALESCE($3, bio),
birthday = COALESCE($4, birthday),
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
color = COALESCE($5, color),
color_grad = COALESCE($6, color_grad)
FROM users FROM users
WHERE username = $1; WHERE username = $1;
@@ -150,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
@@ -200,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 = $2, hide_fulfilled = COALESCE($2, ps.hide_fulfilled),
hide_profile_details = $3, hide_profile_details = COALESCE($3, ps.hide_profile_details),
hide_for_unauthenticated = $4, hide_for_unauthenticated = COALESCE($4, ps.hide_for_unauthenticated),
hide_birthday = $5, hide_birthday = COALESCE($5, ps.hide_birthday),
hide_dates = $6, hide_dates = COALESCE($6, ps.hide_dates),
captcha = $7, captcha = COALESCE($7, ps.captcha),
followers_only_interaction = $8 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
@@ -219,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

@@ -1,10 +1,30 @@
-- 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/>.
;
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; 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
); );
@@ -15,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)
); );
@@ -35,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 UNIQUE 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
); );
@@ -55,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
);

View File

@@ -8,7 +8,7 @@ sql:
out: "../backend/internal/database" out: "../backend/internal/database"
sql_package: "pgx/v5" sql_package: "pgx/v5"
emit_prepared_queries: true emit_prepared_queries: true
emit_interface: false emit_pointers_for_null_types: true
database: database:
# managed: true # managed: true
uri: "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable" uri: "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable"