Compare commits
36 Commits
8b558eaf5f
...
fix-auth_s
| Author | SHA1 | Date | |
|---|---|---|---|
| a5f89c2b32 | |||
| ffcbff5294 | |||
| d7d18f1284 | |||
| b1125d3f6a | |||
| 3bcd8af100 | |||
| b24ffcf3f8 | |||
| 3a63a14c4d | |||
| 5ed75c350a | |||
| 669349e020 | |||
| 8dba0f79aa | |||
| 08b3942d35 | |||
| 0a38267cb0 | |||
| ed044590a0 | |||
| e15ee90a62 | |||
| fbe73e2a68 | |||
| 2809e1bd37 | |||
| f08cb639a5 | |||
| d14f90d628 | |||
| f38af13dc1 | |||
| 705b420b9e | |||
| df54829a67 | |||
| f65439fb50 | |||
| fc0c73aa5b | |||
| 6f7d8bf244 | |||
| 6588190e8b | |||
| 8f04566b5a | |||
| f2274f6c58 | |||
| feb0524d39 | |||
| f2753e1495 | |||
| d6e2d02bff | |||
| f9d7439def | |||
| 7298ab662f | |||
| ec56f64420 | |||
| 249bbe4a98 | |||
| b986d45d82 | |||
| 827928178e |
@@ -45,8 +45,8 @@ import (
|
||||
"easywish/internal/controllers"
|
||||
"easywish/internal/database"
|
||||
"easywish/internal/logger"
|
||||
minioclient "easywish/internal/minioClient"
|
||||
redisclient "easywish/internal/redisClient"
|
||||
"easywish/internal/routes"
|
||||
"easywish/internal/services"
|
||||
"easywish/internal/validation"
|
||||
|
||||
@@ -67,6 +67,7 @@ func main() {
|
||||
logger.NewLogger,
|
||||
logger.NewSyncLogger,
|
||||
redisclient.NewRedisClient,
|
||||
minioclient.NewMinioClient,
|
||||
gin.Default,
|
||||
),
|
||||
database.Module,
|
||||
@@ -74,7 +75,6 @@ func main() {
|
||||
validation.Module,
|
||||
|
||||
controllers.Module,
|
||||
routes.Module,
|
||||
|
||||
fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) {
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -31,6 +32,9 @@ type Config struct {
|
||||
DatabaseUrl string `mapstructure:"POSTGRES_URL"`
|
||||
RedisUrl string `mapstructure:"REDIS_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"`
|
||||
JwtSecret string `mapstructure:"JWT_SECRET"`
|
||||
@@ -65,6 +69,8 @@ func Load() (*Config, error) {
|
||||
viper.SetDefault("HOSTNAME", "localhost")
|
||||
viper.SetDefault("PORT", "8080")
|
||||
|
||||
viper.SetDefault("MINIO_TIMEOUT", 90 * time.Second)
|
||||
|
||||
viper.SetDefault("JWT_ALGORITHM", "HS256")
|
||||
viper.SetDefault("JWT_SECRET", "default_jwt_secret_please_change")
|
||||
viper.SetDefault("JWT_EXP_ACCESS", 5)
|
||||
@@ -97,9 +103,13 @@ func Load() (*Config, error) {
|
||||
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")
|
||||
@@ -132,6 +142,8 @@ func Load() (*Config, error) {
|
||||
"POSTGRES_URL",
|
||||
"REDIS_URL",
|
||||
"MINIO_URL",
|
||||
"MINIO_HOST",
|
||||
"MINIO_PORT",
|
||||
}
|
||||
var missing []string
|
||||
for _, key := range required {
|
||||
|
||||
@@ -18,8 +18,8 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/account/changePassword": {
|
||||
"put": {
|
||||
"/auth/changePassword": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"JWT": []
|
||||
@@ -32,10 +32,28 @@ const docTemplate = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Account"
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Change account password",
|
||||
"responses": {}
|
||||
"summary": "Set new password using the old password",
|
||||
"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": {
|
||||
@@ -252,26 +270,6 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"/profile": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"JWT": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Update profile",
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/profile/me": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
@@ -287,11 +285,54 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Get own profile when authorized",
|
||||
"responses": {}
|
||||
"summary": "Get your profile",
|
||||
"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": {
|
||||
"security": [
|
||||
{
|
||||
@@ -307,10 +348,17 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Get profile privacy settings",
|
||||
"responses": {}
|
||||
"summary": "Get your profile settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": " ",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.ProfileSettingsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"JWT": []
|
||||
@@ -325,8 +373,26 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Update profile privacy settings",
|
||||
"responses": {}
|
||||
"summary": "Update your profile's settings",
|
||||
"parameters": [
|
||||
{
|
||||
"description": " ",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.ProfileSettingsDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": " ",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/profile/{username}": {
|
||||
@@ -345,17 +411,30 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Get someone's profile details",
|
||||
"summary": "Get profile by username",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username",
|
||||
"description": " ",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": " ",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.ProfileDto"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Restricted profile"
|
||||
},
|
||||
"404": {
|
||||
"description": "Profile not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/service/health": {
|
||||
@@ -375,7 +454,51 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Says whether it's healthy or not",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/controllers.HealthStatus"
|
||||
"$ref": "#/definitions/models.HealthStatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/upload/avatar": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"summary": "Get presigned URL for avatar upload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Presigned URL and form data",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.PresignedUploadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/upload/image": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"summary": "Get presigned URL for image upload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Presigned URL and form data",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.PresignedUploadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,7 +506,100 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"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",
|
||||
"properties": {
|
||||
"healthy": {
|
||||
@@ -445,7 +661,7 @@ const docTemplate = `{
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"log_out_accounts": {
|
||||
"log_out_sessions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"password": {
|
||||
@@ -467,6 +683,20 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.PresignedUploadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RefreshRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -474,7 +704,8 @@ const docTemplate = `{
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
},
|
||||
"basePath": "/api/",
|
||||
"paths": {
|
||||
"/account/changePassword": {
|
||||
"put": {
|
||||
"/auth/changePassword": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"JWT": []
|
||||
@@ -28,10 +28,28 @@
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Account"
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Change account password",
|
||||
"responses": {}
|
||||
"summary": "Set new password using the old password",
|
||||
"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": {
|
||||
@@ -248,26 +266,6 @@
|
||||
}
|
||||
},
|
||||
"/profile": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"JWT": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Update profile",
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/profile/me": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
@@ -283,11 +281,54 @@
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Get own profile when authorized",
|
||||
"responses": {}
|
||||
"summary": "Get your profile",
|
||||
"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": {
|
||||
"security": [
|
||||
{
|
||||
@@ -303,10 +344,17 @@
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Get profile privacy settings",
|
||||
"responses": {}
|
||||
"summary": "Get your profile settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": " ",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.ProfileSettingsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"JWT": []
|
||||
@@ -321,8 +369,26 @@
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Update profile privacy settings",
|
||||
"responses": {}
|
||||
"summary": "Update your profile's settings",
|
||||
"parameters": [
|
||||
{
|
||||
"description": " ",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.ProfileSettingsDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": " ",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/profile/{username}": {
|
||||
@@ -341,17 +407,30 @@
|
||||
"tags": [
|
||||
"Profile"
|
||||
],
|
||||
"summary": "Get someone's profile details",
|
||||
"summary": "Get profile by username",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username",
|
||||
"description": " ",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": " ",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.ProfileDto"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Restricted profile"
|
||||
},
|
||||
"404": {
|
||||
"description": "Profile not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/service/health": {
|
||||
@@ -371,7 +450,51 @@
|
||||
"200": {
|
||||
"description": "Says whether it's healthy or not",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/controllers.HealthStatus"
|
||||
"$ref": "#/definitions/models.HealthStatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/upload/avatar": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"summary": "Get presigned URL for avatar upload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Presigned URL and form data",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.PresignedUploadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/upload/image": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"summary": "Get presigned URL for image upload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Presigned URL and form data",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.PresignedUploadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +502,100 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"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",
|
||||
"properties": {
|
||||
"healthy": {
|
||||
@@ -441,7 +657,7 @@
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"log_out_accounts": {
|
||||
"log_out_sessions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"password": {
|
||||
@@ -463,6 +679,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.PresignedUploadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RefreshRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -470,7 +700,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
basePath: /api/
|
||||
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:
|
||||
healthy:
|
||||
type: boolean
|
||||
@@ -38,7 +99,7 @@ definitions:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
log_out_accounts:
|
||||
log_out_sessions:
|
||||
type: boolean
|
||||
password:
|
||||
type: string
|
||||
@@ -56,9 +117,19 @@ definitions:
|
||||
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
|
||||
@@ -113,18 +184,29 @@ info:
|
||||
title: Easywish client API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/account/changePassword:
|
||||
put:
|
||||
/auth/changePassword:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: ' '
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.ChangePasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: Password successfully changed
|
||||
"403":
|
||||
description: Invalid old password
|
||||
security:
|
||||
- JWT: []
|
||||
summary: Change account password
|
||||
summary: Set new password using the old password
|
||||
tags:
|
||||
- Account
|
||||
- Auth
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
@@ -262,15 +344,41 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
/profile:
|
||||
patch:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: ' '
|
||||
schema:
|
||||
$ref: '#/definitions/dto.ProfileDto'
|
||||
security:
|
||||
- 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:
|
||||
- Profile
|
||||
/profile/{username}:
|
||||
@@ -278,52 +386,63 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Username
|
||||
- description: ' '
|
||||
in: path
|
||||
name: username
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: ' '
|
||||
schema:
|
||||
$ref: '#/definitions/dto.ProfileDto'
|
||||
"403":
|
||||
description: Restricted profile
|
||||
"404":
|
||||
description: Profile not found
|
||||
security:
|
||||
- JWT: []
|
||||
summary: Get someone's profile details
|
||||
summary: Get profile by username
|
||||
tags:
|
||||
- Profile
|
||||
/profile/me:
|
||||
/profile/settings:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: ' '
|
||||
schema:
|
||||
$ref: '#/definitions/dto.ProfileSettingsDto'
|
||||
security:
|
||||
- JWT: []
|
||||
summary: Get own profile when authorized
|
||||
summary: Get your profile settings
|
||||
tags:
|
||||
- Profile
|
||||
/profile/privacy:
|
||||
get:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: ' '
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/dto.ProfileSettingsDto'
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: ' '
|
||||
schema:
|
||||
type: boolean
|
||||
security:
|
||||
- JWT: []
|
||||
summary: Get profile privacy settings
|
||||
tags:
|
||||
- Profile
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
security:
|
||||
- JWT: []
|
||||
summary: Update profile privacy settings
|
||||
summary: Update your profile's settings
|
||||
tags:
|
||||
- Profile
|
||||
/service/health:
|
||||
@@ -337,10 +456,38 @@ paths:
|
||||
"200":
|
||||
description: Says whether it's healthy or not
|
||||
schema:
|
||||
$ref: '#/definitions/controllers.HealthStatus'
|
||||
$ref: '#/definitions/models.HealthStatusResponse'
|
||||
summary: Get health status
|
||||
tags:
|
||||
- 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:
|
||||
- http
|
||||
securityDefinitions:
|
||||
|
||||
@@ -5,7 +5,10 @@ go 1.24.3
|
||||
require (
|
||||
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/google/uuid v1.6.0
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
@@ -13,6 +16,7 @@ require (
|
||||
github.com/swaggo/swag v1.16.4
|
||||
go.uber.org/fx v1.24.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,49 +26,55 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/gabriel-vasile/mimetype v1.4.9 // 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/jsonreference 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-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gofrs/uuid/v5 v5.3.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // 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/mailru/easyjson v0.9.0 // 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/reflect2 v1.0.2 // 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/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // 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/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -27,6 +29,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
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/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
@@ -49,8 +53,6 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
|
||||
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -72,9 +74,14 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
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.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -86,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/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/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
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/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/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/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/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/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -127,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/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
@@ -148,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.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.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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
@@ -157,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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -170,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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -179,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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -190,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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -19,7 +19,6 @@ package controllers
|
||||
|
||||
import (
|
||||
errs "easywish/internal/errors"
|
||||
"easywish/internal/middleware"
|
||||
"easywish/internal/models"
|
||||
"easywish/internal/services"
|
||||
"easywish/internal/utils"
|
||||
@@ -31,23 +30,128 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AuthController interface {
|
||||
RegistrationBegin(c *gin.Context)
|
||||
RegistrationComplete(c *gin.Context)
|
||||
Login(c *gin.Context)
|
||||
Refresh(c *gin.Context)
|
||||
PasswordResetBegin(c *gin.Context)
|
||||
PasswordResetComplete(c *gin.Context)
|
||||
Router
|
||||
}
|
||||
|
||||
type authControllerImpl struct {
|
||||
log *zap.Logger
|
||||
type AuthController struct {
|
||||
auth services.AuthService
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewAuthController(_log *zap.Logger, _auth services.AuthService) AuthController {
|
||||
return &authControllerImpl{log: _log, auth: _auth}
|
||||
func NewAuthController(log *zap.Logger, auth services.AuthService) Controller {
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Register an account
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.RegistrationBeginRequest true " "
|
||||
// @Success 200 "Account is created and awaiting verification"
|
||||
// @Failure 409 "Username or email is already taken"
|
||||
// @Failure 429 "Too many recent registration attempts for this email"
|
||||
// @Router /auth/registrationBegin [post]
|
||||
func (ctrl *AuthController) registrationBeginHandler(c *gin.Context) {
|
||||
request, err := GetRequest[models.RegistrationBeginRequest](c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, 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
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// @Summary Confirm with code, finish creating the account
|
||||
// @Tags Auth
|
||||
// @Accept 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]
|
||||
func (ctrl *AuthController) registrationCompleteHandler(c *gin.Context) {
|
||||
request, err := GetRequest[models.RegistrationCompleteRequest](c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := ctrl.auth.RegistrationComplete(request.User, request.Body)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrForbidden) {
|
||||
c.Status(http.StatusForbidden)
|
||||
} else if errors.Is(err, errs.ErrUnauthorized) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
} else {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
|
||||
@@ -58,15 +162,13 @@ func NewAuthController(_log *zap.Logger, _auth services.AuthService) AuthControl
|
||||
// @Success 200 {object} models.LoginResponse " "
|
||||
// @Failure 403 "Invalid login credentials"
|
||||
// @Router /auth/login [post]
|
||||
func (a *authControllerImpl) Login(c *gin.Context) {
|
||||
request, ok := utils.GetRequest[models.LoginRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
func (ctrl *AuthController) loginHandler(c *gin.Context) {
|
||||
request, err := GetRequest[models.LoginRequest](c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.auth.Login(request.Body)
|
||||
|
||||
response, err := ctrl.auth.Login(request.User, request.Body)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrForbidden) {
|
||||
c.Status(http.StatusForbidden)
|
||||
@@ -79,6 +181,41 @@ func (a *authControllerImpl) Login(c *gin.Context) {
|
||||
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
|
||||
@@ -87,14 +224,13 @@ func (a *authControllerImpl) Login(c *gin.Context) {
|
||||
// @Router /auth/passwordResetBegin [post]
|
||||
// @Success 200 "Reset code sent to the email if it is attached to an account"
|
||||
// @Failure 429 "Too many recent requests for this email"
|
||||
func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
|
||||
request, ok := utils.GetRequest[models.PasswordResetBeginRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
func (ctrl *AuthController) passwordResetBeginHandler(c *gin.Context) {
|
||||
request, err := GetRequest[models.PasswordResetBeginRequest](c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.auth.PasswordResetBegin(request.Body)
|
||||
_, err = ctrl.auth.PasswordResetBegin(request.Body)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrTooManyRequests) {
|
||||
c.Status(http.StatusTooManyRequests)
|
||||
@@ -104,7 +240,7 @@ func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// @Summary Complete password reset via email code
|
||||
@@ -115,15 +251,13 @@ func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
|
||||
// @Router /auth/passwordResetComplete [post]
|
||||
// @Success 200 {object} models.PasswordResetCompleteResponse " "
|
||||
// @Success 403 "Wrong verification code or username"
|
||||
func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
|
||||
|
||||
request, ok := utils.GetRequest[models.PasswordResetCompleteRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
func (ctrl *AuthController) passwordResetCompleteHandler(c *gin.Context) {
|
||||
request, err := GetRequest[models.PasswordResetCompleteRequest](c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.auth.PasswordResetComplete(request.Body)
|
||||
response, err := ctrl.auth.PasswordResetComplete(request.Body)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrForbidden) {
|
||||
c.Status(http.StatusForbidden)
|
||||
@@ -136,66 +270,25 @@ func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
|
||||
// @Summary Receive new tokens via refresh token
|
||||
// @Summary Set new password using the old password
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.RefreshRequest true " "
|
||||
// @Router /auth/refresh [post]
|
||||
// @Success 200 {object} models.RefreshResponse " "
|
||||
// @Failure 401 "Invalid refresh token"
|
||||
func (a *authControllerImpl) Refresh(c *gin.Context) {
|
||||
|
||||
request, ok := utils.GetRequest[models.RefreshRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.auth.Refresh(request.Body)
|
||||
// @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 {
|
||||
if errors.Is(err, errs.ErrTokenExpired) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token is expired"})
|
||||
} else if errors.Is(err, errs.ErrTokenInvalid) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token is invalid"})
|
||||
} else if errors.Is(err, errs.ErrWrongTokenType) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token type"})
|
||||
} else if errors.Is(err, errs.ErrSessionNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Could not find session in database"})
|
||||
} else if errors.Is(err, errs.ErrSessionTerminated) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session is terminated"})
|
||||
} else {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// @Summary Register an account
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.RegistrationBeginRequest true " "
|
||||
// @Success 200 "Account is created and awaiting verification"
|
||||
// @Failure 409 "Username or email is already taken"
|
||||
// @Failure 429 "Too many recent registration attempts for this email"
|
||||
// @Router /auth/registrationBegin [post]
|
||||
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
||||
|
||||
request, ok := utils.GetRequest[models.RegistrationBeginRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := a.auth.RegistrationBegin(request.Body)
|
||||
|
||||
_, err = ctrl.auth.ChangePassword(request.Body, request.User)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
|
||||
c.Status(http.StatusConflict)
|
||||
if errors.Is(err, errs.ErrForbidden) {
|
||||
c.Status(http.StatusForbidden)
|
||||
} else {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
@@ -203,45 +296,4 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Confirm with code, finish creating the account
|
||||
// @Tags Auth
|
||||
// @Accept 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]
|
||||
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
|
||||
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.auth.RegistrationComplete(request.Body)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrForbidden) {
|
||||
c.Status(http.StatusForbidden)
|
||||
} else if errors.Is(err, errs.ErrUnauthorized) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
} else {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin)
|
||||
group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete)
|
||||
group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login)
|
||||
group.POST("/refresh", middleware.RequestMiddleware[models.RefreshRequest](enums.GuestRole), a.Refresh)
|
||||
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.PasswordResetBeginRequest](enums.GuestRole), a.PasswordResetBegin)
|
||||
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.PasswordResetCompleteRequest](enums.GuestRole), a.PasswordResetComplete)
|
||||
}
|
||||
|
||||
154
backend/internal/controllers/controller.go
Normal file
154
backend/internal/controllers/controller.go
Normal 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
|
||||
}
|
||||
@@ -18,77 +18,180 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"easywish/internal/dto"
|
||||
errs "easywish/internal/errors"
|
||||
"easywish/internal/services"
|
||||
"easywish/internal/utils/enums"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProfileController interface {
|
||||
GetProfile(c *gin.Context)
|
||||
GetOwnProfile(c *gin.Context)
|
||||
UpdateProfile(c *gin.Context)
|
||||
GetPrivacySettings(c *gin.Context)
|
||||
UpdatePrivacySettings(c *gin.Context)
|
||||
Router
|
||||
type ProfileController struct {
|
||||
log *zap.Logger
|
||||
ps services.ProfileService
|
||||
}
|
||||
|
||||
type profileControllerImpl struct {
|
||||
func NewProfileController(_log *zap.Logger, _ps services.ProfileService) Controller {
|
||||
|
||||
ctrl := ProfileController{log: _log, ps: _ps}
|
||||
|
||||
return &controllerImpl{
|
||||
Path: "/profile",
|
||||
Middleware: []gin.HandlerFunc{},
|
||||
Methods: []ControllerMethod{
|
||||
{
|
||||
HttpMethod: GET,
|
||||
Path: "",
|
||||
Authorization: enums.UserRole,
|
||||
Middleware: []gin.HandlerFunc{},
|
||||
Function: ctrl.getMyProfile,
|
||||
},
|
||||
{
|
||||
HttpMethod: GET,
|
||||
Path: "/:username",
|
||||
Authorization: enums.GuestRole,
|
||||
Middleware: []gin.HandlerFunc{},
|
||||
Function: ctrl.getProfileByUsername,
|
||||
},
|
||||
{
|
||||
HttpMethod: GET,
|
||||
Path: "/settings",
|
||||
Authorization: enums.UserRole,
|
||||
Middleware: []gin.HandlerFunc{},
|
||||
Function: ctrl.getProfileSettings,
|
||||
},
|
||||
{
|
||||
HttpMethod: PUT,
|
||||
Path: "",
|
||||
Authorization: enums.UserRole,
|
||||
Middleware: []gin.HandlerFunc{},
|
||||
Function: ctrl.updateProfile,
|
||||
},
|
||||
{
|
||||
HttpMethod: PUT,
|
||||
Path: "/settings",
|
||||
Authorization: enums.UserRole,
|
||||
Middleware: []gin.HandlerFunc{},
|
||||
Function: ctrl.updateProfileSettings,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewProfileController() ProfileController {
|
||||
return &profileControllerImpl{}
|
||||
}
|
||||
|
||||
// @Summary Get someone's profile details
|
||||
// @Summary Get your profile
|
||||
// @Tags Profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param username path string true "Username"
|
||||
// @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 (p *profileControllerImpl) GetProfile(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
|
||||
cinfo := GetClientInfo(c)
|
||||
|
||||
username := c.Param("username"); if username == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Get own profile when authorized
|
||||
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
|
||||
// @Router /profile/me [get]
|
||||
func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
// @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
|
||||
}
|
||||
|
||||
// @Summary Update profile
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// @Summary Update your profile
|
||||
// @Tags Profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWT
|
||||
// @Router /profile [patch]
|
||||
func (p *profileControllerImpl) UpdateProfile(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
// @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
|
||||
}
|
||||
|
||||
// @Summary Get profile privacy settings
|
||||
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
|
||||
// @Router /profile/privacy [get]
|
||||
func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
// @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
|
||||
}
|
||||
|
||||
// @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)
|
||||
response, err := ctrl.ps.UpdateProfileSettings(request.User, request.Body); if err != nil || !response {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
func (p *profileControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
102
backend/internal/controllers/s3.go
Normal file
102
backend/internal/controllers/s3.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -18,39 +18,43 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"easywish/internal/models"
|
||||
"easywish/internal/utils/enums"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ServiceController interface {
|
||||
HealthCheck(c *gin.Context)
|
||||
Router
|
||||
type ServiceController struct {}
|
||||
|
||||
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
|
||||
// @Description Used internally for checking service health
|
||||
// @Tags Service
|
||||
// @Accept 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]
|
||||
func (s *serviceControllerImpl) HealthCheck(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"healthy": true})
|
||||
}
|
||||
|
||||
// RegisterRoutes implements ServiceController.
|
||||
func (s *serviceControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/health", s.HealthCheck)
|
||||
}
|
||||
|
||||
type HealthStatus struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
func (ctrl *ServiceController) healthHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,})
|
||||
}
|
||||
|
||||
@@ -18,13 +18,57 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"easywish/internal/dto"
|
||||
"easywish/internal/middleware"
|
||||
"easywish/internal/services"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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",
|
||||
fx.Provide(
|
||||
NewServiceController,
|
||||
NewAuthController,
|
||||
NewProfileController,
|
||||
fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)),
|
||||
fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)),
|
||||
fx.Annotate(NewProfileController, fx.ResultTags(`group:"controllers"`)),
|
||||
fx.Annotate(NewS3Controller, fx.ResultTags(`group:"controllers"`)),
|
||||
),
|
||||
fx.Invoke(setupControllers),
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ type dbHelperTransactionImpl struct {
|
||||
TXlessQueries Queries
|
||||
TX pgx.Tx
|
||||
TXQueries Queries
|
||||
isCommited bool
|
||||
}
|
||||
|
||||
func NewDbHelper(dbContext DbContext) DbHelper {
|
||||
@@ -79,30 +80,24 @@ func (d *dbHelperTransactionImpl) Commit() error {
|
||||
errCommit := d.TX.Commit(d.CTX)
|
||||
|
||||
if errCommit != nil {
|
||||
errRollback := d.TX.Rollback(d.CTX)
|
||||
|
||||
if errRollback != nil {
|
||||
return errRollback
|
||||
d.isCommited = true
|
||||
}
|
||||
|
||||
return errCommit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rollback implements DbHelperTransaction.
|
||||
func (d *dbHelperTransactionImpl) Rollback() error {
|
||||
err := d.TX.Rollback(d.CTX)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.isCommited {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.TX.Rollback(d.CTX);
|
||||
}
|
||||
|
||||
// RollbackOnError implements DbHelperTransaction.
|
||||
func (d *dbHelperTransactionImpl) RollbackOnError(err error) error {
|
||||
if err != nil {
|
||||
if d.isCommited || err == nil {
|
||||
return d.Rollback()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -15,7 +15,7 @@ type BannedUser struct {
|
||||
Reason *string
|
||||
ExpiresAt pgtype.Timestamp
|
||||
BannedBy *string
|
||||
Pardoned *bool
|
||||
Pardoned bool
|
||||
PardonedBy *string
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ type ConfirmationCode struct {
|
||||
CodeType int32
|
||||
CodeHash string
|
||||
ExpiresAt pgtype.Timestamp
|
||||
Used *bool
|
||||
Deleted *bool
|
||||
Used bool
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
type LoginInformation struct {
|
||||
@@ -43,23 +43,23 @@ type Profile struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Name string
|
||||
Bio *string
|
||||
AvatarUrl *string
|
||||
Bio string
|
||||
AvatarUrl string
|
||||
Birthday pgtype.Timestamp
|
||||
Color *string
|
||||
ColorGrad *string
|
||||
Color string
|
||||
ColorGrad string
|
||||
}
|
||||
|
||||
type ProfileSetting struct {
|
||||
ID int64
|
||||
ProfileID int64
|
||||
HideFulfilled *bool
|
||||
HideProfileDetails *bool
|
||||
HideForUnauthenticated *bool
|
||||
HideBirthday *bool
|
||||
HideDates *bool
|
||||
Captcha *bool
|
||||
FollowersOnlyInteraction *bool
|
||||
HideFulfilled bool
|
||||
HideProfileDetails bool
|
||||
HideForUnauthenticated bool
|
||||
HideBirthday bool
|
||||
HideDates bool
|
||||
Captcha bool
|
||||
FollowersOnlyInteraction bool
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
@@ -78,7 +78,34 @@ type Session struct {
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
Verified *bool
|
||||
Verified bool
|
||||
RegistrationDate pgtype.Timestamp
|
||||
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
|
||||
}
|
||||
|
||||
@@ -146,11 +146,11 @@ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, user_id, name, bio, avatar_url
|
||||
type CreateProfileParams struct {
|
||||
UserID int64
|
||||
Name string
|
||||
Bio *string
|
||||
Bio string
|
||||
Birthday pgtype.Timestamp
|
||||
AvatarUrl *string
|
||||
Color *string
|
||||
ColorGrad *string
|
||||
AvatarUrl string
|
||||
Color string
|
||||
ColorGrad string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateProfile(ctx context.Context, arg CreateProfileParams) (Profile, error) {
|
||||
@@ -236,7 +236,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users(username, verified)
|
||||
VALUES ($1, false) RETURNING id, username, verified, registration_date, deleted
|
||||
VALUES ($1, false) RETURNING id, username, verified, registration_date, role, deleted
|
||||
`
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, username string) (User, error) {
|
||||
@@ -247,6 +247,55 @@ func (q *Queries) CreateUser(ctx context.Context, username string) (User, error)
|
||||
&i.Username,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Role,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createWishList = `-- name: CreateWishList :one
|
||||
INSERT INTO wish_lists(profile_id, hidden, name, icon_name, color, color_grad)
|
||||
VALUES (
|
||||
(SELECT p.id FROM profiles AS p
|
||||
JOIN users AS u ON u.id = p.user_id
|
||||
WHERE u.username = $1::text),
|
||||
$2::boolean,
|
||||
$3::text,
|
||||
$4::text,
|
||||
$5::text,
|
||||
$6::boolean
|
||||
)
|
||||
RETURNING id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted
|
||||
`
|
||||
|
||||
type CreateWishListParams struct {
|
||||
Username string
|
||||
Hidden bool
|
||||
Name string
|
||||
IconName string
|
||||
Color string
|
||||
ColorGrad bool
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWishList(ctx context.Context, arg CreateWishListParams) (WishList, error) {
|
||||
row := q.db.QueryRow(ctx, createWishList,
|
||||
arg.Username,
|
||||
arg.Hidden,
|
||||
arg.Name,
|
||||
arg.IconName,
|
||||
arg.Color,
|
||||
arg.ColorGrad,
|
||||
)
|
||||
var i WishList
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Guid,
|
||||
&i.ProfileID,
|
||||
&i.Hidden,
|
||||
&i.Name,
|
||||
&i.IconName,
|
||||
&i.Color,
|
||||
&i.ColorGrad,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
@@ -264,7 +313,7 @@ WITH deleted_rows AS (
|
||||
AND linfo.email = $2::text
|
||||
))
|
||||
AND verified IS FALSE
|
||||
RETURNING id, username, verified, registration_date, deleted
|
||||
RETURNING id, username, verified, registration_date, role, deleted
|
||||
)
|
||||
SELECT COUNT(*) AS deleted_count FROM deleted_rows
|
||||
`
|
||||
@@ -344,59 +393,65 @@ func (q *Queries) GetProfileByUsername(ctx context.Context, username string) (Pr
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProfileByUsernameRestricted = `-- name: GetProfileByUsernameRestricted :one
|
||||
const getProfileByUsernameWithPrivacy = `-- name: GetProfileByUsernameWithPrivacy :one
|
||||
SELECT
|
||||
users.username,
|
||||
profiles.name,
|
||||
CASE
|
||||
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL
|
||||
ELSE profiles.birthday
|
||||
END AS birthday,
|
||||
CASE
|
||||
WHEN profile_settings.hide_profile_details THEN NULL
|
||||
ELSE profiles.bio
|
||||
END AS bio,
|
||||
CASE
|
||||
WHEN profile_settings.hide_profile_details THEN NULL
|
||||
ELSE profiles.avatar_url
|
||||
END AS avatar_url,
|
||||
profiles.color,
|
||||
profiles.color_grad,
|
||||
profile_settings.hide_profile_details
|
||||
FROM profiles
|
||||
JOIN users ON users.id = profiles.user_id
|
||||
JOIN profile_settings ON profiles.id = profile_settings.profile_id
|
||||
WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE)
|
||||
u.username,
|
||||
p.name,
|
||||
p.bio,
|
||||
p.avatar_url,
|
||||
CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
|
||||
p.color,
|
||||
p.color_grad,
|
||||
NOT ($1::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
|
||||
FROM
|
||||
users AS u
|
||||
JOIN profiles AS p ON u.id = p.user_id
|
||||
JOIN profile_settings AS ps ON p.id = ps.profile_id
|
||||
WHERE
|
||||
u.username = $2::text
|
||||
AND (
|
||||
$2::text = $1::text
|
||||
OR
|
||||
u.deleted IS FALSE
|
||||
AND u.verified IS TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM banned_users
|
||||
WHERE user_id = u.id AND
|
||||
pardoned IS FALSE AND
|
||||
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
type GetProfileByUsernameRestrictedParams struct {
|
||||
Username string
|
||||
Column2 *bool
|
||||
type GetProfileByUsernameWithPrivacyParams struct {
|
||||
Requester string
|
||||
SearchedUsername string
|
||||
}
|
||||
|
||||
type GetProfileByUsernameRestrictedRow struct {
|
||||
type GetProfileByUsernameWithPrivacyRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Bio string
|
||||
AvatarUrl string
|
||||
Birthday pgtype.Timestamp
|
||||
Bio *string
|
||||
AvatarUrl *string
|
||||
Color *string
|
||||
ColorGrad *string
|
||||
HideProfileDetails *bool
|
||||
Color string
|
||||
ColorGrad string
|
||||
AccessAllowed *bool
|
||||
}
|
||||
|
||||
func (q *Queries) GetProfileByUsernameRestricted(ctx context.Context, arg GetProfileByUsernameRestrictedParams) (GetProfileByUsernameRestrictedRow, error) {
|
||||
row := q.db.QueryRow(ctx, getProfileByUsernameRestricted, arg.Username, arg.Column2)
|
||||
var i GetProfileByUsernameRestrictedRow
|
||||
func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetProfileByUsernameWithPrivacyParams) (GetProfileByUsernameWithPrivacyRow, error) {
|
||||
row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, arg.Requester, arg.SearchedUsername)
|
||||
var i GetProfileByUsernameWithPrivacyRow
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Birthday,
|
||||
&i.Bio,
|
||||
&i.AvatarUrl,
|
||||
&i.Birthday,
|
||||
&i.Color,
|
||||
&i.ColorGrad,
|
||||
&i.HideProfileDetails,
|
||||
&i.AccessAllowed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -453,9 +508,9 @@ type GetProfilesRestrictedRow struct {
|
||||
Username string
|
||||
Name string
|
||||
AvatarUrl *string
|
||||
Color *string
|
||||
ColorGrad *string
|
||||
HideProfileDetails *bool
|
||||
Color string
|
||||
ColorGrad string
|
||||
HideProfileDetails bool
|
||||
}
|
||||
|
||||
func (q *Queries) GetProfilesRestricted(ctx context.Context, arg GetProfilesRestrictedParams) ([]GetProfilesRestrictedRow, error) {
|
||||
@@ -508,15 +563,22 @@ func (q *Queries) GetSessionByGuid(ctx context.Context, guid string) (Session, e
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUnexpiredTerminatedSessionsGuids = `-- name: GetUnexpiredTerminatedSessionsGuids :many
|
||||
const getUnexpiredTerminatedSessionsGuidsPaginated = `-- name: GetUnexpiredTerminatedSessionsGuidsPaginated :many
|
||||
SELECT guid FROM sessions
|
||||
WHERE
|
||||
terminated IS TRUE AND
|
||||
last_refresh_exp_time > CURRENT_TIMESTAMP
|
||||
LIMIT $1::integer
|
||||
OFFSET $2
|
||||
`
|
||||
|
||||
func (q *Queries) GetUnexpiredTerminatedSessionsGuids(ctx context.Context) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, getUnexpiredTerminatedSessionsGuids)
|
||||
type GetUnexpiredTerminatedSessionsGuidsPaginatedParams struct {
|
||||
BatchSize int32
|
||||
Offset int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetUnexpiredTerminatedSessionsGuidsPaginated(ctx context.Context, arg GetUnexpiredTerminatedSessionsGuidsPaginatedParams) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, getUnexpiredTerminatedSessionsGuidsPaginated, arg.BatchSize, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -536,7 +598,7 @@ func (q *Queries) GetUnexpiredTerminatedSessionsGuids(ctx context.Context) ([]pg
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, username, verified, registration_date, deleted FROM users
|
||||
SELECT id, username, verified, registration_date, role, deleted FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -548,6 +610,7 @@ func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
|
||||
&i.Username,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Role,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
@@ -623,7 +686,7 @@ func (q *Queries) GetUserBansByUsername(ctx context.Context, username string) ([
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT users.id, users.username, users.verified, users.registration_date, users.deleted FROM users
|
||||
SELECT users.id, users.username, users.verified, users.registration_date, users.role, users.deleted FROM users
|
||||
JOIN login_informations linfo ON linfo.user_id = users.id
|
||||
WHERE linfo.email = $1::text
|
||||
`
|
||||
@@ -636,13 +699,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
|
||||
&i.Username,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Role,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, verified, registration_date, deleted FROM users
|
||||
SELECT id, username, verified, registration_date, role, deleted FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
|
||||
@@ -654,6 +718,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.Username,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Role,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
@@ -691,7 +756,7 @@ func (q *Queries) GetValidConfirmationCodeByCode(ctx context.Context, arg GetVal
|
||||
}
|
||||
|
||||
const getValidConfirmationCodesByUsername = `-- name: GetValidConfirmationCodesByUsername :many
|
||||
SELECT confirmation_codes.id, user_id, code_type, code_hash, expires_at, used, confirmation_codes.deleted, users.id, username, verified, registration_date, users.deleted FROM confirmation_codes
|
||||
SELECT confirmation_codes.id, user_id, code_type, code_hash, expires_at, used, confirmation_codes.deleted, users.id, username, verified, registration_date, role, users.deleted FROM confirmation_codes
|
||||
JOIN users on users.id = confirmation_codes.user_id
|
||||
WHERE
|
||||
users.username = $1::text AND
|
||||
@@ -711,12 +776,13 @@ type GetValidConfirmationCodesByUsernameRow struct {
|
||||
CodeType int32
|
||||
CodeHash string
|
||||
ExpiresAt pgtype.Timestamp
|
||||
Used *bool
|
||||
Deleted *bool
|
||||
Used bool
|
||||
Deleted bool
|
||||
ID_2 int64
|
||||
Username string
|
||||
Verified *bool
|
||||
Verified bool
|
||||
RegistrationDate pgtype.Timestamp
|
||||
Role int32
|
||||
Deleted_2 *bool
|
||||
}
|
||||
|
||||
@@ -741,6 +807,7 @@ func (q *Queries) GetValidConfirmationCodesByUsername(ctx context.Context, arg G
|
||||
&i.Username,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Role,
|
||||
&i.Deleted_2,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -755,18 +822,22 @@ func (q *Queries) GetValidConfirmationCodesByUsername(ctx context.Context, arg G
|
||||
|
||||
const getValidUserByLoginCredentials = `-- name: GetValidUserByLoginCredentials :one
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
users.id, users.username, users.verified, users.registration_date, users.role, users.deleted,
|
||||
linfo.password_hash,
|
||||
linfo.totp_encrypted
|
||||
FROM users
|
||||
JOIN login_informations AS linfo ON users.id = linfo.user_id
|
||||
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
|
||||
WHERE
|
||||
users.username = $1 AND
|
||||
users.verified IS TRUE AND -- Verified
|
||||
users.deleted IS FALSE AND -- Not deleted
|
||||
banned.user_id IS NULL AND -- Not banned
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM banned_users
|
||||
WHERE user_id = users.id AND
|
||||
pardoned IS FALSE AND
|
||||
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
|
||||
) AND -- Not banned
|
||||
linfo.password_hash = crypt($2::text, linfo.password_hash)
|
||||
`
|
||||
|
||||
@@ -778,6 +849,10 @@ type GetValidUserByLoginCredentialsParams struct {
|
||||
type GetValidUserByLoginCredentialsRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
Verified bool
|
||||
RegistrationDate pgtype.Timestamp
|
||||
Role int32
|
||||
Deleted *bool
|
||||
PasswordHash string
|
||||
TotpEncrypted *string
|
||||
}
|
||||
@@ -788,6 +863,10 @@ func (q *Queries) GetValidUserByLoginCredentials(ctx context.Context, arg GetVal
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Role,
|
||||
&i.Deleted,
|
||||
&i.PasswordHash,
|
||||
&i.TotpEncrypted,
|
||||
)
|
||||
@@ -832,6 +911,108 @@ func (q *Queries) GetValidUserSessions(ctx context.Context, userID int64) ([]Ses
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getWishlistByGuid = `-- name: GetWishlistByGuid :one
|
||||
SELECT id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted FROM wish_lists wl
|
||||
WHERE wl.guid = ($1::text)::uuid
|
||||
`
|
||||
|
||||
func (q *Queries) GetWishlistByGuid(ctx context.Context, guid string) (WishList, error) {
|
||||
row := q.db.QueryRow(ctx, getWishlistByGuid, guid)
|
||||
var i WishList
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Guid,
|
||||
&i.ProfileID,
|
||||
&i.Hidden,
|
||||
&i.Name,
|
||||
&i.IconName,
|
||||
&i.Color,
|
||||
&i.ColorGrad,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWishlistsByUsernameWithPrivacy = `-- name: GetWishlistsByUsernameWithPrivacy :many
|
||||
SELECT
|
||||
wl.id, wl.guid, wl.profile_id, wl.hidden, wl.name, wl.icon_name, wl.color, wl.color_grad, wl.deleted,
|
||||
CASE
|
||||
WHEN (ps.hide_profile_details OR ps.hide_for_unauthenticated) 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 = $1::text AND
|
||||
(
|
||||
u.username = $2::text OR
|
||||
(u.verified IS TRUE AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM banned_users
|
||||
WHERE user_id = u.id AND
|
||||
pardoned IS FALSE AND
|
||||
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
|
||||
))
|
||||
)
|
||||
`
|
||||
|
||||
type GetWishlistsByUsernameWithPrivacyParams struct {
|
||||
Username string
|
||||
Requester string
|
||||
}
|
||||
|
||||
type GetWishlistsByUsernameWithPrivacyRow struct {
|
||||
ID int64
|
||||
Guid pgtype.UUID
|
||||
ProfileID int64
|
||||
Hidden bool
|
||||
Name string
|
||||
IconName *string
|
||||
Color *string
|
||||
ColorGrad *string
|
||||
Deleted bool
|
||||
AccessAllowed bool
|
||||
}
|
||||
|
||||
func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg GetWishlistsByUsernameWithPrivacyParams) ([]GetWishlistsByUsernameWithPrivacyRow, error) {
|
||||
rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Username, arg.Requester)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetWishlistsByUsernameWithPrivacyRow
|
||||
for rows.Next() {
|
||||
var i GetWishlistsByUsernameWithPrivacyRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Guid,
|
||||
&i.ProfileID,
|
||||
&i.Hidden,
|
||||
&i.Name,
|
||||
&i.IconName,
|
||||
&i.Color,
|
||||
&i.ColorGrad,
|
||||
&i.Deleted,
|
||||
&i.AccessAllowed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
|
||||
DELETE FROM confirmation_codes
|
||||
WHERE expires_at < CURRENT_TIMESTAMP
|
||||
@@ -852,16 +1033,32 @@ func (q *Queries) PruneTerminatedSessions(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const terminateAllSessionsForUserByUsername = `-- name: TerminateAllSessionsForUserByUsername :exec
|
||||
const terminateAllSessionsForUserByUsername = `-- name: TerminateAllSessionsForUserByUsername :many
|
||||
UPDATE sessions
|
||||
SET terminated = TRUE
|
||||
FROM users
|
||||
WHERE sessions.user_id = users.id AND users.username = $1::text
|
||||
RETURNING sessions.guid
|
||||
`
|
||||
|
||||
func (q *Queries) TerminateAllSessionsForUserByUsername(ctx context.Context, username string) error {
|
||||
_, err := q.db.Exec(ctx, terminateAllSessionsForUserByUsername, username)
|
||||
return err
|
||||
func (q *Queries) TerminateAllSessionsForUserByUsername(ctx context.Context, username string) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, terminateAllSessionsForUserByUsername, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var guid pgtype.UUID
|
||||
if err := rows.Scan(&guid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, guid)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateBannedUser = `-- name: UpdateBannedUser :exec
|
||||
@@ -880,7 +1077,7 @@ type UpdateBannedUserParams struct {
|
||||
Reason *string
|
||||
ExpiresAt pgtype.Timestamp
|
||||
BannedBy *string
|
||||
Pardoned *bool
|
||||
Pardoned bool
|
||||
PardonedBy *string
|
||||
}
|
||||
|
||||
@@ -906,8 +1103,8 @@ WHERE id = $1
|
||||
|
||||
type UpdateConfirmationCodeParams struct {
|
||||
ID int64
|
||||
Used *bool
|
||||
Deleted *bool
|
||||
Used bool
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateConfirmationCode(ctx context.Context, arg UpdateConfirmationCodeParams) error {
|
||||
@@ -954,9 +1151,9 @@ SET
|
||||
name = COALESCE($2, name),
|
||||
bio = COALESCE($3, bio),
|
||||
birthday = COALESCE($4, birthday),
|
||||
avatar_url = COALESCE($5, avatar_url),
|
||||
color = COALESCE($6, color),
|
||||
color_grad = COALESCE($7, color_grad)
|
||||
avatar_url = COALESCE($7, avatar_url),
|
||||
color = COALESCE($5, color),
|
||||
color_grad = COALESCE($6, color_grad)
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
@@ -964,11 +1161,11 @@ WHERE username = $1
|
||||
type UpdateProfileByUsernameParams struct {
|
||||
Username string
|
||||
Name string
|
||||
Bio *string
|
||||
Bio string
|
||||
Birthday pgtype.Timestamp
|
||||
Color string
|
||||
ColorGrad string
|
||||
AvatarUrl *string
|
||||
Color *string
|
||||
ColorGrad *string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfileByUsernameParams) error {
|
||||
@@ -977,40 +1174,42 @@ func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfile
|
||||
arg.Name,
|
||||
arg.Bio,
|
||||
arg.Birthday,
|
||||
arg.AvatarUrl,
|
||||
arg.Color,
|
||||
arg.ColorGrad,
|
||||
arg.AvatarUrl,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateProfileSettings = `-- name: UpdateProfileSettings :exec
|
||||
UPDATE profile_settings
|
||||
const updateProfileSettingsByUsername = `-- name: UpdateProfileSettingsByUsername :exec
|
||||
UPDATE profile_settings ps
|
||||
SET
|
||||
hide_fulfilled = COALESCE($2, hide_fulfilled),
|
||||
hide_profile_details = COALESCE($3, hide_profile_details),
|
||||
hide_for_unauthenticated = COALESCE($4, hide_for_unauthenticated),
|
||||
hide_birthday = COALESCE($5, hide_birthday),
|
||||
hide_dates = COALESCE($6, hide_dates),
|
||||
captcha = COALESCE($7, captcha),
|
||||
followers_only_interaction = COALESCE($8, followers_only_interaction)
|
||||
WHERE id = $1
|
||||
hide_fulfilled = COALESCE($2, ps.hide_fulfilled),
|
||||
hide_profile_details = COALESCE($3, ps.hide_profile_details),
|
||||
hide_for_unauthenticated = COALESCE($4, ps.hide_for_unauthenticated),
|
||||
hide_birthday = COALESCE($5, ps.hide_birthday),
|
||||
hide_dates = COALESCE($6, ps.hide_dates),
|
||||
captcha = COALESCE($7, ps.captcha),
|
||||
followers_only_interaction = COALESCE($8, ps.followers_only_interaction)
|
||||
FROM profiles p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE ps.profile_id = p.id AND u.username = $1
|
||||
`
|
||||
|
||||
type UpdateProfileSettingsParams struct {
|
||||
ID int64
|
||||
HideFulfilled *bool
|
||||
HideProfileDetails *bool
|
||||
HideForUnauthenticated *bool
|
||||
HideBirthday *bool
|
||||
HideDates *bool
|
||||
Captcha *bool
|
||||
FollowersOnlyInteraction *bool
|
||||
type UpdateProfileSettingsByUsernameParams struct {
|
||||
Username string
|
||||
HideFulfilled bool
|
||||
HideProfileDetails bool
|
||||
HideForUnauthenticated bool
|
||||
HideBirthday bool
|
||||
HideDates bool
|
||||
Captcha bool
|
||||
FollowersOnlyInteraction bool
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateProfileSettings(ctx context.Context, arg UpdateProfileSettingsParams) error {
|
||||
_, err := q.db.Exec(ctx, updateProfileSettings,
|
||||
arg.ID,
|
||||
func (q *Queries) UpdateProfileSettingsByUsername(ctx context.Context, arg UpdateProfileSettingsByUsernameParams) error {
|
||||
_, err := q.db.Exec(ctx, updateProfileSettingsByUsername,
|
||||
arg.Username,
|
||||
arg.HideFulfilled,
|
||||
arg.HideProfileDetails,
|
||||
arg.HideForUnauthenticated,
|
||||
@@ -1070,7 +1269,7 @@ WHERE id = $1
|
||||
|
||||
type UpdateUserParams struct {
|
||||
ID int64
|
||||
Verified *bool
|
||||
Verified bool
|
||||
Deleted *bool
|
||||
}
|
||||
|
||||
@@ -1087,7 +1286,7 @@ WHERE username = $1
|
||||
|
||||
type UpdateUserByUsernameParams struct {
|
||||
Username string
|
||||
Verified *bool
|
||||
Verified bool
|
||||
Deleted *bool
|
||||
}
|
||||
|
||||
@@ -1095,3 +1294,76 @@ func (q *Queries) UpdateUserByUsername(ctx context.Context, arg UpdateUserByUser
|
||||
_, err := q.db.Exec(ctx, updateUserByUsername, arg.Username, arg.Verified, arg.Deleted)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWishByGuid = `-- name: UpdateWishByGuid :exec
|
||||
UPDATE wishes w
|
||||
SET
|
||||
name = COALESCE($1::text, w.name),
|
||||
description = COALESCE($2::text, w.description),
|
||||
picture_url = COALESCE($3::text, w.picture_url),
|
||||
stars = COALESCE($4::smallint, w.stars),
|
||||
fulfilled = COALESCE($5::boolean, w.fulfilled),
|
||||
fulfilled_date = COALESCE($6::timestamp, w.fulfilled_date),
|
||||
deleted = COALESCE($7::boolean, w.deleted)
|
||||
WHERE w.guid = ($8::text)::uuid
|
||||
`
|
||||
|
||||
type UpdateWishByGuidParams struct {
|
||||
Name string
|
||||
Description string
|
||||
PictureUrl string
|
||||
Stars int16
|
||||
Fulfilled bool
|
||||
FulfilledDate pgtype.Timestamp
|
||||
Deleted bool
|
||||
Guid string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWishByGuid(ctx context.Context, arg UpdateWishByGuidParams) error {
|
||||
_, err := q.db.Exec(ctx, updateWishByGuid,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.PictureUrl,
|
||||
arg.Stars,
|
||||
arg.Fulfilled,
|
||||
arg.FulfilledDate,
|
||||
arg.Deleted,
|
||||
arg.Guid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWishListByGuid = `-- name: UpdateWishListByGuid :exec
|
||||
UPDATE wish_lists wl
|
||||
SET
|
||||
hidden = COALESCE($1::boolean, wl.hidden),
|
||||
name = COALESCE($2::text, wl.name),
|
||||
icon_name = COALESCE($3::text, wl.icon_name),
|
||||
color = COALESCE($4::text, wl.color),
|
||||
color_grad = COALESCE($5::text, wl.color_grad),
|
||||
deleted = COALESCE($6::boolean, wl.deleted)
|
||||
WHERE wl.guid = ($7::text)::uuid
|
||||
`
|
||||
|
||||
type UpdateWishListByGuidParams struct {
|
||||
Hidden bool
|
||||
Name string
|
||||
IconName string
|
||||
Color string
|
||||
ColorGrad string
|
||||
Deleted bool
|
||||
Guid string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWishListByGuid(ctx context.Context, arg UpdateWishListByGuidParams) error {
|
||||
_, err := q.db.Exec(ctx, updateWishListByGuid,
|
||||
arg.Hidden,
|
||||
arg.Name,
|
||||
arg.IconName,
|
||||
arg.Color,
|
||||
arg.ColorGrad,
|
||||
arg.Deleted,
|
||||
arg.Guid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,20 +15,8 @@
|
||||
// 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 dto
|
||||
|
||||
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)
|
||||
type UrlDto struct {
|
||||
Url string `json:"url" binding:"required"`
|
||||
}
|
||||
46
backend/internal/dto/profile.go
Normal file
46
backend/internal/dto/profile.go
Normal 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"`
|
||||
}
|
||||
@@ -31,7 +31,7 @@ var (
|
||||
ErrServerError = errors.New("Internal server error")
|
||||
|
||||
ErrTokenExpired = errors.New("Token is expired")
|
||||
ErrTokenInvalid = errors.New("Token is invalid")
|
||||
ErrTokenInvalid = ErrInvalidToken
|
||||
ErrWrongTokenType = errors.New("Invalid token type")
|
||||
ErrSessionNotFound = errors.New("Could not find session in database")
|
||||
ErrSessionTerminated = errors.New("Session is terminated")
|
||||
|
||||
@@ -15,15 +15,10 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package routes
|
||||
package errors
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
import "errors"
|
||||
|
||||
var Module = fx.Module("routes",
|
||||
fx.Provide(
|
||||
NewRouteGroups,
|
||||
),
|
||||
fx.Invoke(NewRouter),
|
||||
var (
|
||||
ErrClientInfoNotProvided = errors.New("No client info provded")
|
||||
)
|
||||
@@ -26,4 +26,5 @@ var (
|
||||
ErrBadRequest = errors.New("Bad request")
|
||||
ErrForbidden = errors.New("Access is denied")
|
||||
ErrTooManyRequests = errors.New("Too many requests")
|
||||
ErrNotFound = errors.New("Resource not found")
|
||||
)
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
// 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 errors
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Router interface {
|
||||
RegisterRoutes(group *gin.RouterGroup)
|
||||
}
|
||||
var (
|
||||
ErrFileNotFound = errors.New("File with this key does not exist")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
|
||||
@@ -61,7 +61,7 @@ func AuthMiddleware(log *zap.Logger, auth services.AuthService) gin.HandlerFunc
|
||||
}
|
||||
return
|
||||
} else {
|
||||
c.Set("session_info", sessionInfo)
|
||||
c.Set("session_info", *sessionInfo)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,23 @@
|
||||
// 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 middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"easywish/config"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc {
|
||||
limiter := rate.NewLimiter(r, b)
|
||||
|
||||
return conn, ctx, nil
|
||||
return func(c *gin.Context) {
|
||||
if !limiter.Allow() {
|
||||
c.AbortWithStatus(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"easywish/internal/dto"
|
||||
"easywish/internal/utils/enums"
|
||||
"easywish/internal/validation"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
const requestKey = "request"
|
||||
|
||||
func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) {
|
||||
|
||||
var ok bool
|
||||
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
sessionInfoFromCtx, ok := c.Get("session_info"); if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sessionInfo := sessionInfoFromCtx.(dto.SessionInfo)
|
||||
|
||||
if sessionInfo.Username == "" {
|
||||
return &dto.ClientInfo{
|
||||
SessionInfo: sessionInfo,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}, true
|
||||
}
|
||||
|
||||
return &dto.ClientInfo{
|
||||
SessionInfo: sessionInfo,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}, true
|
||||
}
|
||||
|
||||
func RequestFromContext[T any](c *gin.Context) dto.Request[T] {
|
||||
return c.Value(requestKey).(dto.Request[T])
|
||||
}
|
||||
|
||||
func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
|
||||
return gin.HandlerFunc(func(c *gin.Context) {
|
||||
|
||||
clientInfo, ok := ClientInfoFromContext(c)
|
||||
|
||||
if !ok {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if clientInfo.Role < role {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var body T
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
validate := validation.NewValidator()
|
||||
|
||||
if err := validate.Struct(body); err != nil {
|
||||
errorList := err.(validator.ValidationErrors)
|
||||
c.String(http.StatusBadRequest, fmt.Sprintf("Validation error: %s", errorList))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
request := dto.Request[T]{
|
||||
User: *clientInfo,
|
||||
Body: body,
|
||||
}
|
||||
|
||||
c.Set(requestKey, request)
|
||||
c.Next()
|
||||
})
|
||||
}
|
||||
87
backend/internal/minioClient/buckets.go
Normal file
87
backend/internal/minioClient/buckets.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
69
backend/internal/minioClient/ginEndpoint.go
Normal file
69
backend/internal/minioClient/ginEndpoint.go
Normal 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)
|
||||
})
|
||||
}
|
||||
61
backend/internal/minioClient/minioClient.go
Normal file
61
backend/internal/minioClient/minioClient.go
Normal 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
|
||||
}
|
||||
@@ -32,7 +32,7 @@ type RegistrationCompleteRequest struct {
|
||||
Username string `json:"username" binding:"required" validate:"username"`
|
||||
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"`
|
||||
Name string `json:"name" binding:"required" validate:"name"`
|
||||
Birthday *string `json:"birthday"`
|
||||
Birthday int64 `json:"birthday" validate:"birthday_unix_milli"`
|
||||
}
|
||||
|
||||
type RegistrationCompleteResponse struct {
|
||||
@@ -49,9 +49,8 @@ type LoginResponse struct {
|
||||
Tokens
|
||||
}
|
||||
|
||||
// TODO: length check
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
RefreshToken string `json:"refresh_token" binding:"required,max=2000"`
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
@@ -72,3 +71,13 @@ type PasswordResetCompleteRequest struct {
|
||||
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
|
||||
}
|
||||
|
||||
18
backend/internal/models/profile.go
Normal file
18
backend/internal/models/profile.go
Normal 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
|
||||
@@ -15,20 +15,10 @@
|
||||
// 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 models
|
||||
|
||||
import (
|
||||
"easywish/internal/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetRequest[T any](c *gin.Context) (*dto.Request[T], bool) {
|
||||
|
||||
req, ok := c.Get("request")
|
||||
request := req.(dto.Request[T])
|
||||
if !ok {
|
||||
return nil, false
|
||||
type PresignedUploadResponse struct {
|
||||
Url string `json:"url"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
|
||||
return &request, true
|
||||
}
|
||||
22
backend/internal/models/service.go
Normal file
22
backend/internal/models/service.go
Normal 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"`
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package routes
|
||||
|
||||
import (
|
||||
"easywish/internal/controllers"
|
||||
"easywish/internal/middleware"
|
||||
"easywish/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func NewRouter(engine *gin.Engine, log *zap.Logger, auth services.AuthService, groups []RouteGroup) *gin.Engine {
|
||||
apiGroup := engine.Group("/api")
|
||||
apiGroup.Use(middleware.AuthMiddleware(log, auth))
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -35,16 +35,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
AuthTerminatedSessionCacheDuration = time.Duration(8 * time.Hour)
|
||||
AuthRegistrationCooldownCacheDuration = time.Duration(10 * time.Minute)
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
RegistrationBegin(request models.RegistrationBeginRequest) (bool, error)
|
||||
RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error)
|
||||
Login(request models.LoginRequest) (*models.LoginResponse, error)
|
||||
RegistrationComplete(cinfo dto.ClientInfo, request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error)
|
||||
Login(cinfo dto.ClientInfo, request models.LoginRequest) (*models.LoginResponse, error)
|
||||
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
|
||||
PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error)
|
||||
PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error)
|
||||
ChangePassword(request models.ChangePasswordRequest, cinfo dto.ClientInfo) (bool, error)
|
||||
ValidateToken(token string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error)
|
||||
}
|
||||
|
||||
@@ -56,29 +63,110 @@ type authServiceImpl struct {
|
||||
}
|
||||
|
||||
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _smtp SmtpService) AuthService {
|
||||
|
||||
authService := &authServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, smtp: _smtp}
|
||||
|
||||
// Cache terminated sessions
|
||||
// FIXME: review possible RAM overflow
|
||||
ctx := context.TODO()
|
||||
db := database.NewDbHelper(_dbctx)
|
||||
guids, err := db.Queries.GetUnexpiredTerminatedSessionsGuids(db.CTX)
|
||||
|
||||
// Batch processing parameters
|
||||
batchSize := 1000
|
||||
offset := 0
|
||||
totalCached := 0
|
||||
|
||||
for {
|
||||
guids, err := db.Queries.GetUnexpiredTerminatedSessionsGuidsPaginated(
|
||||
db.CTX,
|
||||
database.GetUnexpiredTerminatedSessionsGuidsPaginatedParams{
|
||||
BatchSize: int32(batchSize),
|
||||
Offset: int64(offset),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic("Failed to load terminated sessions' GUIDs")
|
||||
panic("Failed to load terminated sessions' GUIDs: " + err.Error())
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
// FIXME: review possible problems due to a large pipeline request
|
||||
// Break loop when no more records
|
||||
if len(guids) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Process batch in Redis pipeline
|
||||
pipe := _redis.Pipeline()
|
||||
for _, guid := range guids {
|
||||
if err := pipe.Set(ctx, fmt.Sprintf("session::%s::is_terminated", guid), true, 0).Err(); err != nil {
|
||||
panic("Failed to cache terminated session: " + err.Error())
|
||||
key := fmt.Sprintf("session::%s::is_terminated", guid)
|
||||
pipe.Set(ctx, key, true, AuthTerminatedSessionCacheDuration)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
panic("Failed to cache terminated sessions: " + err.Error())
|
||||
}
|
||||
_log.Info("Cached terminated sessions' GUIDs in Redis", zap.Int("amount", len(guids)))
|
||||
|
||||
totalCached += len(guids)
|
||||
offset += len(guids)
|
||||
|
||||
_log.Info(
|
||||
"Cached batch of terminated sessions",
|
||||
zap.Int("batch_size", len(guids)),
|
||||
zap.Int("total_cached", totalCached))
|
||||
}
|
||||
|
||||
_log.Info("Finished caching terminated sessions",
|
||||
zap.Int("total_sessions", totalCached),
|
||||
)
|
||||
|
||||
return authService
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) terminateAllSessionsForUser(ctx context.Context, username string, queries *database.Queries) error {
|
||||
|
||||
sessionGuids, err := queries.TerminateAllSessionsForUserByUsername(ctx, username); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to terminate older sessions for user trying to log in",
|
||||
zap.String("username", username),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
pipe := a.redis.Pipeline()
|
||||
for _, guid := range sessionGuids {
|
||||
pipe.Set(
|
||||
ctx,
|
||||
fmt.Sprintf("session::%s::is_terminated", guid),
|
||||
true,
|
||||
AuthTerminatedSessionCacheDuration,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to cache terminated sessions",
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) registerSession(ctx context.Context, userID int64, cinfo dto.ClientInfo, queries *database.Queries) (*database.Session, error) {
|
||||
|
||||
session, err := queries.CreateSession(ctx, database.CreateSessionParams{
|
||||
UserID: userID,
|
||||
Name: utils.NewPointer(cinfo.UserAgent),
|
||||
Platform: utils.NewPointer(cinfo.UserAgent),
|
||||
LatestIp: utils.NewPointer(cinfo.IP),
|
||||
}); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to add session to database",
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.log.Info(
|
||||
"Registered a new user session",
|
||||
zap.String("username", cinfo.Username),
|
||||
zap.String("session", cinfo.Session))
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) {
|
||||
|
||||
var occupationStatus database.CheckUserRegistrationAvailabilityRow
|
||||
@@ -95,14 +183,13 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
|
||||
zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
defer helper.RollbackOnError(err)
|
||||
|
||||
if isInProgress, err := a.redis.Get(
|
||||
isInProgress, err := a.redis.Get(
|
||||
context.TODO(),
|
||||
fmt.Sprintf("email::%s::registration_in_progress",
|
||||
request.Email),
|
||||
).Bool(); err != nil {
|
||||
).Bool(); if err != nil {
|
||||
if err != redis.Nil {
|
||||
a.log.Error(
|
||||
"Failed to look up cached registration_in_progress state of email as part of registration procedure",
|
||||
@@ -143,7 +230,7 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
|
||||
context.TODO(),
|
||||
fmt.Sprintf("email::%s::registration_in_progress", request.Email),
|
||||
true,
|
||||
time.Duration(10 * time.Minute), // XXX: magic number
|
||||
AuthRegistrationCooldownCacheDuration,
|
||||
).Err(); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to falsely set cache registration_in_progress state for email as a measure to prevent email enumeration",
|
||||
@@ -248,7 +335,7 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
|
||||
context.TODO(),
|
||||
fmt.Sprintf("email::%s::registration_in_progress", request.Email),
|
||||
true,
|
||||
time.Duration(10 * time.Minute), // XXX: magic number
|
||||
AuthTerminatedSessionCacheDuration,
|
||||
).Err(); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to cache registration_in_progress state for email",
|
||||
@@ -261,17 +348,24 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
|
||||
a.log.Error(
|
||||
"Failed to commit transaction",
|
||||
zap.Error(err))
|
||||
|
||||
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::registration_in_progress", request.Email))
|
||||
if redisErr != nil {
|
||||
a.log.Error(
|
||||
"Failed to delete cooldown redis key while rolling back RegistrationBegin",
|
||||
zap.Error(redisErr.Err()))
|
||||
}
|
||||
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) {
|
||||
func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) {
|
||||
|
||||
var user database.User
|
||||
var profile database.Profile
|
||||
var session database.Session
|
||||
var confirmationCode database.ConfirmationCode
|
||||
var accessToken, refreshToken string
|
||||
var err error
|
||||
@@ -328,7 +422,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
|
||||
|
||||
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
|
||||
ID: confirmationCode.ID,
|
||||
Used: utils.NewPointer(true),
|
||||
Used: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -342,7 +436,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
|
||||
|
||||
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
|
||||
ID: user.ID,
|
||||
Verified: utils.NewPointer(true),
|
||||
Verified: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -352,15 +446,20 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
birthdayTimestamp := pgtype.Timestamp {
|
||||
Time: time.UnixMilli(request.Birthday),
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{
|
||||
UserID: user.ID,
|
||||
Name: request.Name,
|
||||
Birthday: birthdayTimestamp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Error("Failed to create profile for user",
|
||||
zap.String("username", user.Username),
|
||||
|
||||
)
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
@@ -374,24 +473,12 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
// TODO: session info
|
||||
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
|
||||
UserID: user.ID,
|
||||
Name: utils.NewPointer("First device"),
|
||||
Platform: utils.NewPointer("Unknown"),
|
||||
LatestIp: utils.NewPointer("Unknown"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to create a new session during registration, rolling back registration",
|
||||
zap.String("username", user.Username),
|
||||
zap.Error(err))
|
||||
session, err := a.registerSession(context.TODO(), user.ID, cinfo, &db.TXQueries); if err != nil {
|
||||
a.log.Error("", zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
// TODO: get user role
|
||||
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String(), enums.UserRole)
|
||||
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String(), enums.Role(user.Role))
|
||||
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
@@ -420,10 +507,8 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// TODO: totp
|
||||
func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) {
|
||||
func (a *authServiceImpl) Login(cinfo dto.ClientInfo, request models.LoginRequest) (*models.LoginResponse, error) {
|
||||
var userRow database.GetValidUserByLoginCredentialsRow
|
||||
var session database.Session
|
||||
var err error
|
||||
|
||||
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
|
||||
@@ -438,12 +523,7 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
|
||||
userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{
|
||||
Username: request.Username,
|
||||
Password: request.Password,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Warn(
|
||||
"Failed login attempt",
|
||||
zap.Error(err))
|
||||
}); if err != nil {
|
||||
|
||||
var returnedError error
|
||||
|
||||
@@ -454,37 +534,30 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
|
||||
returnedError = errs.ErrServerError
|
||||
}
|
||||
|
||||
a.log.Warn(
|
||||
"Failed login attempt",
|
||||
zap.Error(err))
|
||||
return nil, returnedError
|
||||
}
|
||||
|
||||
// Until release 4, only 1 session at a time is supported
|
||||
if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, request.Username); err != nil {
|
||||
err = a.terminateAllSessionsForUser(context.TODO(), request.Username, &db.TXQueries); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to terminate older sessions for user trying to log in",
|
||||
zap.String("username", request.Username),
|
||||
"Failed to terminate user's sessions during login",
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
|
||||
// TODO: use actual values for session metadata
|
||||
UserID: userRow.ID,
|
||||
Name: utils.NewPointer("New device"),
|
||||
Platform: utils.NewPointer("Unknown"),
|
||||
LatestIp: utils.NewPointer("Unknown"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to create session for a new login",
|
||||
zap.String("username", userRow.Username),
|
||||
zap.Error(err))
|
||||
session, err := a.registerSession(context.TODO(), userRow.ID, cinfo, &db.TXQueries); if err != nil {
|
||||
a.log.Error("", zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
// TODO: get user role
|
||||
accessToken, refreshToken, err := utils.GenerateTokens(userRow.Username, session.Guid.String(), enums.UserRole)
|
||||
if err != nil {
|
||||
accessToken, refreshToken, err := utils.GenerateTokens(
|
||||
userRow.Username,
|
||||
session.Guid.String(),
|
||||
enums.Role(userRow.Role),
|
||||
); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to generate tokens for a new login",
|
||||
zap.String("username", userRow.Username),
|
||||
@@ -509,7 +582,8 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
|
||||
|
||||
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
|
||||
|
||||
sessionInfo, err := a.ValidateToken(request.RefreshToken, enums.JwtRefreshTokenType); if err != nil {
|
||||
sessionInfo, err := a.ValidateToken(request.RefreshToken, enums.JwtRefreshTokenType)
|
||||
if err != nil {
|
||||
|
||||
if utils.ErrorIsOneOf(
|
||||
err,
|
||||
@@ -532,7 +606,8 @@ func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.Refres
|
||||
sessionInfo.Username,
|
||||
sessionInfo.Session,
|
||||
sessionInfo.Role,
|
||||
); if err != nil {
|
||||
)
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to generate tokens for user during refresh",
|
||||
zap.String("username", sessionInfo.Username),
|
||||
@@ -573,7 +648,8 @@ func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtToke
|
||||
return nil, errs.ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*dto.UserClaims); if ok && token.Valid {
|
||||
claims, ok := token.Claims.(*dto.UserClaims)
|
||||
if ok && token.Valid {
|
||||
|
||||
if claims.Type != tokenType {
|
||||
return nil, errs.ErrWrongTokenType
|
||||
@@ -613,7 +689,7 @@ func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtToke
|
||||
ctx,
|
||||
fmt.Sprintf("session::%s::is_terminated", claims.Session),
|
||||
*session.Terminated,
|
||||
time.Duration(8 * time.Hour), // XXX: magic number
|
||||
AuthTerminatedSessionCacheDuration,
|
||||
).Err(); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to cache session's is_terminated state",
|
||||
@@ -740,6 +816,14 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
|
||||
a.log.Error(
|
||||
"Failed to commit transaction",
|
||||
zap.Error(err))
|
||||
|
||||
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::reset_cooldown", request.Email))
|
||||
if redisErr != nil {
|
||||
a.log.Error(
|
||||
"Failed to delete cooldown redis key while rolling back PasswordResetBegin",
|
||||
zap.Error(redisErr.Err()))
|
||||
}
|
||||
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
@@ -796,7 +880,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
|
||||
|
||||
if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
|
||||
ID: resetCode.ID,
|
||||
Used: utils.NewPointer(true),
|
||||
Used: true,
|
||||
}); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to invalidate password reset code upon use",
|
||||
@@ -828,22 +912,21 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
|
||||
}
|
||||
|
||||
if request.LogOutSessions {
|
||||
if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, user.Username); err != nil {
|
||||
err = a.terminateAllSessionsForUser(context.TODO(), user.Username, &db.TXQueries); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to log out older sessions as part of user password reset",
|
||||
zap.String("email", request.Email),
|
||||
zap.String("username", user.Username),
|
||||
"Failed to terminate user's sessions during login",
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
}
|
||||
|
||||
if session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
|
||||
// FIXME: grab client info
|
||||
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
|
||||
UserID: user.ID,
|
||||
Name: utils.NewPointer("First device"),
|
||||
Platform: utils.NewPointer("Unknown"),
|
||||
LatestIp: utils.NewPointer("Unknown"),
|
||||
}); err != nil {
|
||||
}); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to create new session for user as part of user password reset",
|
||||
zap.String("email", request.Email),
|
||||
@@ -851,8 +934,11 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// TODO: get user role
|
||||
if accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String(), enums.UserRole); err != nil {
|
||||
if accessToken, refreshToken, err = utils.GenerateTokens(
|
||||
user.Username,
|
||||
session.Guid.String(),
|
||||
enums.UserRole,
|
||||
); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to generate tokens as part of user password reset",
|
||||
zap.String("email", request.Email),
|
||||
@@ -878,3 +964,58 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// XXX: Mechanism for loging out existing sessions currently does not exist
|
||||
func (a *authServiceImpl) ChangePassword(request models.ChangePasswordRequest, uinfo dto.ClientInfo) (bool, error) {
|
||||
|
||||
var err error
|
||||
|
||||
helper, db, err := database.NewDbHelperTransaction(a.dbctx); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to open a transaction",
|
||||
zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
defer helper.RollbackOnError(err)
|
||||
|
||||
linfo, err := db.TXQueries.GetLoginInformationByUsername(db.CTX, uinfo.Username); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to get user login information",
|
||||
zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordHash(request.OldPassword, linfo.PasswordHash) {
|
||||
a.log.Warn(
|
||||
"Provided invalid old password while changing password",
|
||||
zap.String("username", uinfo.Username))
|
||||
return false, errs.ErrForbidden
|
||||
}
|
||||
|
||||
newPasswordHash, err := utils.HashPassword(request.NewPassword); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to hash new password while changing password",
|
||||
zap.String("username", uinfo.Username),
|
||||
zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
|
||||
Username: uinfo.Username,
|
||||
PasswordHash: newPasswordHash,
|
||||
}); if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to save new password into database",
|
||||
zap.String("username", uinfo.Username),
|
||||
zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
if err := helper.Commit(); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to commit transaction",
|
||||
zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
224
backend/internal/services/profile.go
Normal file
224
backend/internal/services/profile.go
Normal 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
|
||||
}
|
||||
176
backend/internal/services/s3.go
Normal file
176
backend/internal/services/s3.go
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import (
|
||||
|
||||
var Module = fx.Module("services",
|
||||
fx.Provide(
|
||||
NewUploadService,
|
||||
NewSmtpService,
|
||||
NewAuthService,
|
||||
NewProfileService,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ import "errors"
|
||||
func ErrorIsOneOf(err error, ignoreErrors ...error) bool {
|
||||
for _, ignore := range ignoreErrors {
|
||||
if errors.Is(err, ignore) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
34
backend/internal/utils/mapSpecial/profileDto.go
Normal file
34
backend/internal/utils/mapSpecial/profileDto.go
Normal 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()
|
||||
})
|
||||
}
|
||||
45
backend/internal/utils/minio.go
Normal file
45
backend/internal/utils/minio.go
Normal 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
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"easywish/config"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
@@ -48,6 +49,36 @@ func GetCustomHandlers() []CustomValidatorHandler {
|
||||
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 {
|
||||
@@ -97,6 +128,20 @@ func GetCustomHandlers() []CustomValidatorHandler {
|
||||
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
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
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}
|
||||
@@ -60,6 +62,7 @@ services:
|
||||
|
||||
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
|
||||
@@ -70,7 +73,8 @@ services:
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
command: server /data
|
||||
- "9001:9001"
|
||||
- "8021:8021"
|
||||
networks:
|
||||
- easywish-network
|
||||
volumes:
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
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}
|
||||
|
||||
207
sqlc/query.sql
207
sqlc/query.sql
@@ -32,7 +32,9 @@ WHERE id = $1;
|
||||
|
||||
;-- name: UpdateUserByUsername :exec
|
||||
UPDATE users
|
||||
SET verified = $2, deleted = $3
|
||||
SET
|
||||
verified = COALESCE($2, verified),
|
||||
deleted = COALESCE($3, deleted)
|
||||
WHERE username = $1;
|
||||
|
||||
;-- name: DeleteUser :exec
|
||||
@@ -56,43 +58,24 @@ SELECT users.* FROM users
|
||||
JOIN login_informations linfo ON linfo.user_id = users.id
|
||||
WHERE linfo.email = @email::text;
|
||||
|
||||
;-- name: CheckUserRegistrationAvailability :one
|
||||
-- SELECT
|
||||
-- COUNT(users.username = @username::text) > 0 AS username_busy,
|
||||
-- COUNT(linfo.email = @email::text) > 0 AS email_busy
|
||||
-- FROM users
|
||||
-- JOIN login_informations AS linfo on linfo.user_id = users.id
|
||||
-- WHERE
|
||||
-- (
|
||||
-- users.username = @username::text OR
|
||||
-- linfo.email = @email::text
|
||||
-- )
|
||||
-- AND
|
||||
-- (
|
||||
-- users.verified IS TRUE OR
|
||||
-- COUNT(
|
||||
-- SELECT confirmation_codes as codes
|
||||
-- JOIN users on users.id = codes.user_id
|
||||
-- WHERE codes.code_type = 0 AND
|
||||
-- codes.deleted IS FALSE AND
|
||||
-- codes.expires_at < CURRENT_TIMESTAMP
|
||||
-- ) = 0;
|
||||
-- )
|
||||
|
||||
;-- name: GetValidUserByLoginCredentials :one
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
users.*,
|
||||
linfo.password_hash,
|
||||
linfo.totp_encrypted
|
||||
FROM users
|
||||
JOIN login_informations AS linfo ON users.id = linfo.user_id
|
||||
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
|
||||
WHERE
|
||||
users.username = $1 AND
|
||||
users.verified IS TRUE AND -- Verified
|
||||
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
|
||||
|
||||
;-- name: CheckUserRegistrationAvailability :one
|
||||
@@ -253,17 +236,20 @@ WHERE
|
||||
user_id = $1 AND terminated IS FALSE AND
|
||||
last_refresh_exp_time > CURRENT_TIMESTAMP;
|
||||
|
||||
;-- name: GetUnexpiredTerminatedSessionsGuids :many
|
||||
-- name: GetUnexpiredTerminatedSessionsGuidsPaginated :many
|
||||
SELECT guid FROM sessions
|
||||
WHERE
|
||||
terminated IS TRUE AND
|
||||
last_refresh_exp_time > CURRENT_TIMESTAMP;
|
||||
last_refresh_exp_time > CURRENT_TIMESTAMP
|
||||
LIMIT @batch_size::integer
|
||||
OFFSET $2;
|
||||
|
||||
;-- name: TerminateAllSessionsForUserByUsername :exec
|
||||
;-- name: TerminateAllSessionsForUserByUsername :many
|
||||
UPDATE sessions
|
||||
SET terminated = TRUE
|
||||
FROM users
|
||||
WHERE sessions.user_id = users.id AND users.username = @username::text;
|
||||
WHERE sessions.user_id = users.id AND users.username = @username::text
|
||||
RETURNING sessions.guid;
|
||||
|
||||
;-- name: PruneTerminatedSessions :exec
|
||||
DELETE FROM sessions
|
||||
@@ -283,9 +269,9 @@ SET
|
||||
name = COALESCE($2, name),
|
||||
bio = COALESCE($3, bio),
|
||||
birthday = COALESCE($4, birthday),
|
||||
avatar_url = COALESCE($5, avatar_url),
|
||||
color = COALESCE($6, color),
|
||||
color_grad = COALESCE($7, color_grad)
|
||||
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
|
||||
color = COALESCE($5, color),
|
||||
color_grad = COALESCE($6, color_grad)
|
||||
FROM users
|
||||
WHERE username = $1;
|
||||
|
||||
@@ -294,29 +280,35 @@ SELECT profiles.* FROM profiles
|
||||
JOIN users ON users.id = profiles.user_id
|
||||
WHERE users.username = $1;
|
||||
|
||||
;-- name: GetProfileByUsernameRestricted :one
|
||||
;-- name: GetProfileByUsernameWithPrivacy :one
|
||||
SELECT
|
||||
users.username,
|
||||
profiles.name,
|
||||
CASE
|
||||
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL
|
||||
ELSE profiles.birthday
|
||||
END AS birthday,
|
||||
CASE
|
||||
WHEN profile_settings.hide_profile_details THEN NULL
|
||||
ELSE profiles.bio
|
||||
END AS bio,
|
||||
CASE
|
||||
WHEN profile_settings.hide_profile_details THEN NULL
|
||||
ELSE profiles.avatar_url
|
||||
END AS avatar_url,
|
||||
profiles.color,
|
||||
profiles.color_grad,
|
||||
profile_settings.hide_profile_details
|
||||
FROM profiles
|
||||
JOIN users ON users.id = profiles.user_id
|
||||
JOIN profile_settings ON profiles.id = profile_settings.profile_id
|
||||
WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE);
|
||||
u.username,
|
||||
p.name,
|
||||
p.bio,
|
||||
p.avatar_url,
|
||||
CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
|
||||
p.color,
|
||||
p.color_grad,
|
||||
NOT (@requester::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
|
||||
FROM
|
||||
users AS u
|
||||
JOIN profiles AS p ON u.id = p.user_id
|
||||
JOIN profile_settings AS ps ON p.id = ps.profile_id
|
||||
WHERE
|
||||
u.username = @searched_username::text
|
||||
AND (
|
||||
@searched_username::text = @requester::text
|
||||
OR
|
||||
u.deleted IS FALSE
|
||||
AND 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)
|
||||
)
|
||||
);
|
||||
|
||||
;-- name: GetProfilesRestricted :many
|
||||
SELECT
|
||||
@@ -344,17 +336,19 @@ LIMIT 20 OFFSET 20 * $1;
|
||||
INSERT INTO profile_settings(profile_id)
|
||||
VALUES ($1) RETURNING *;
|
||||
|
||||
;-- name: UpdateProfileSettings :exec
|
||||
UPDATE profile_settings
|
||||
;-- name: UpdateProfileSettingsByUsername :exec
|
||||
UPDATE profile_settings ps
|
||||
SET
|
||||
hide_fulfilled = COALESCE($2, hide_fulfilled),
|
||||
hide_profile_details = COALESCE($3, hide_profile_details),
|
||||
hide_for_unauthenticated = COALESCE($4, hide_for_unauthenticated),
|
||||
hide_birthday = COALESCE($5, hide_birthday),
|
||||
hide_dates = COALESCE($6, hide_dates),
|
||||
captcha = COALESCE($7, captcha),
|
||||
followers_only_interaction = COALESCE($8, followers_only_interaction)
|
||||
WHERE id = $1;
|
||||
hide_fulfilled = COALESCE($2, ps.hide_fulfilled),
|
||||
hide_profile_details = COALESCE($3, ps.hide_profile_details),
|
||||
hide_for_unauthenticated = COALESCE($4, ps.hide_for_unauthenticated),
|
||||
hide_birthday = COALESCE($5, ps.hide_birthday),
|
||||
hide_dates = COALESCE($6, ps.hide_dates),
|
||||
captcha = COALESCE($7, ps.captcha),
|
||||
followers_only_interaction = COALESCE($8, ps.followers_only_interaction)
|
||||
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
|
||||
SELECT profile_settings.* FROM profile_settings
|
||||
@@ -363,3 +357,82 @@ JOIN users ON users.id = profiles.user_id
|
||||
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;
|
||||
|
||||
--: }}}
|
||||
|
||||
@@ -22,8 +22,9 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(20) UNIQUE NOT NULL,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
registration_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
role INTEGER NOT NULL DEFAULT 1, -- enum user
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
@@ -34,7 +35,7 @@ CREATE TABLE IF NOT EXISTS "banned_users" (
|
||||
reason VARCHAR(512),
|
||||
expires_at TIMESTAMP,
|
||||
banned_by VARCHAR(20) DEFAULT 'system',
|
||||
pardoned BOOLEAN DEFAULT FALSE,
|
||||
pardoned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
pardoned_by VARCHAR(20)
|
||||
);
|
||||
|
||||
@@ -54,16 +55,16 @@ CREATE TABLE IF NOT EXISTS "confirmation_codes" (
|
||||
code_type INTEGER NOT NULL CHECK (code_type IN (0, 1)),
|
||||
code_hash VARCHAR(512) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '10 minutes',
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
guid UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100),
|
||||
platform VARCHAR(32),
|
||||
guid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(175),
|
||||
platform VARCHAR(175),
|
||||
latest_ip VARCHAR(16),
|
||||
login_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_refresh_exp_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '10080 seconds',
|
||||
@@ -75,21 +76,47 @@ CREATE TABLE IF NOT EXISTS "profiles" (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(75) NOT NULL,
|
||||
bio VARCHAR(512),
|
||||
avatar_url VARCHAR(255),
|
||||
bio VARCHAR(512) NOT NULL DEFAULT '',
|
||||
avatar_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
birthday TIMESTAMP,
|
||||
color VARCHAR(7),
|
||||
color_grad VARCHAR(7)
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#254333',
|
||||
color_grad VARCHAR(7) NOT NULL DEFAULT '#691E4D'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "profile_settings" (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
profile_id BIGINT UNIQUE NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
hide_fulfilled BOOLEAN DEFAULT TRUE,
|
||||
hide_profile_details BOOLEAN DEFAULT FALSE,
|
||||
hide_for_unauthenticated BOOLEAN DEFAULT FALSE,
|
||||
hide_birthday BOOLEAN DEFAULT FALSE,
|
||||
hide_dates BOOLEAN DEFAULT FALSE,
|
||||
captcha BOOLEAN DEFAULT FALSE,
|
||||
followers_only_interaction BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
hide_fulfilled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
hide_profile_details BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
hide_for_unauthenticated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
hide_birthday BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
hide_dates BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
captcha BOOLEAN NOT NULL 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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user