Compare commits
15 Commits
cbcfb8a286
...
15c140db31
| Author | SHA1 | Date | |
|---|---|---|---|
| 15c140db31 | |||
| 63b63038d1 | |||
| b5fdcd5dca | |||
| 72a512bb4f | |||
| 8588a17928 | |||
| bc9f5c6d3c | |||
| 333817c9e1 | |||
| 5e32c3cbd3 | |||
| 8319afc7ea | |||
| 0a51727af8 | |||
| d08db300fc | |||
| 96e41efdec | |||
| 284d959bc3 | |||
| e2d83aa779 | |||
| 69d55ce060 |
@@ -33,6 +33,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -46,6 +47,7 @@ import (
|
||||
"easywish/internal/logger"
|
||||
"easywish/internal/routes"
|
||||
"easywish/internal/services"
|
||||
"easywish/internal/validation"
|
||||
|
||||
swaggerfiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
@@ -65,6 +67,8 @@ func main() {
|
||||
),
|
||||
database.Module,
|
||||
services.Module,
|
||||
validation.Module,
|
||||
|
||||
controllers.Module,
|
||||
routes.Module,
|
||||
|
||||
@@ -76,7 +80,7 @@ func main() {
|
||||
|
||||
// Gin
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", config.GetConfig().Port),
|
||||
Addr: fmt.Sprintf(":%s", strconv.Itoa(int(config.GetConfig().Port))),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
@@ -105,5 +109,5 @@ func main() {
|
||||
})
|
||||
}),
|
||||
|
||||
).Run()
|
||||
).Run()
|
||||
}
|
||||
|
||||
@@ -25,27 +25,39 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Hostname string `mapstructure:"HOSTNAME"`
|
||||
Port string `mapstructure:"PORT"`
|
||||
Hostname string `mapstructure:"HOSTNAME"`
|
||||
Port uint16 `mapstructure:"PORT"`
|
||||
|
||||
DatabaseUrl string `mapstructure:"POSTGRES_URL"`
|
||||
RedisUrl string `mapstructure:"REDIS_URL"`
|
||||
MinioUrl string `mapstructure:"MINIO_URL"`
|
||||
DatabaseUrl string `mapstructure:"POSTGRES_URL"`
|
||||
RedisUrl string `mapstructure:"REDIS_URL"`
|
||||
MinioUrl string `mapstructure:"MINIO_URL"`
|
||||
|
||||
JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"`
|
||||
JwtSecret string `mapstructure:"JWT_SECRET"`
|
||||
JwtIssuer string `mapstructure:"JWT_ISSUER"`
|
||||
JwtAudience string `mapstructure:"JWT_AUDIENCE"`
|
||||
JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"`
|
||||
JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"`
|
||||
JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"`
|
||||
JwtSecret string `mapstructure:"JWT_SECRET"`
|
||||
JwtIssuer string `mapstructure:"JWT_ISSUER"`
|
||||
JwtAudience string `mapstructure:"JWT_AUDIENCE"`
|
||||
JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"`
|
||||
JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"`
|
||||
|
||||
PasswordCheckLength bool `mapstructure:"PASSWORD_CHECK_LENGTH"`
|
||||
PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"`
|
||||
PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"`
|
||||
PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"`
|
||||
PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"`
|
||||
SmtpEnabled bool `mapstructure:"SMTP_ENABLED"`
|
||||
SmtpServer string `mapstructure:"SMTP_SERVER"`
|
||||
SmtpPort uint16 `mapstructure:"SMTP_PORT"`
|
||||
SmtpUser string `mapstructure:"SMTP_USER"`
|
||||
SmtpPassword string `mapstructure:"SMTP_PASSWORD"`
|
||||
SmtpFrom string `mapstructure:"SMTP_FROM"`
|
||||
SmtpUseTLS bool `mapstructure:"SMTP_USE_TLS"`
|
||||
SmtpUseSSL bool `mapstructure:"SMTP_USE_SSL"`
|
||||
SmtpTimeout uint `mapstructure:"SMTP_TIMEOUT"`
|
||||
|
||||
Environment string `mapstructure:"ENVIRONMENT"`
|
||||
PasswordMinLength int `mapstructure:"PASSWORD_MIN_LENGTH"`
|
||||
PasswordMaxLength int `mapstructure:"PASSWORD_MAX_LENGTH"`
|
||||
PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"`
|
||||
PasswordCheckCharacters bool `mapstructure:"PASSWORD_CHECK_CHARACTERS"`
|
||||
PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"`
|
||||
PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"`
|
||||
PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"`
|
||||
|
||||
Environment string `mapstructure:"ENVIRONMENT"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -60,8 +72,15 @@ func Load() (*Config, error) {
|
||||
viper.SetDefault("JWT_AUDIENCE", "easywish")
|
||||
viper.SetDefault("JWT_ISSUER", "easywish")
|
||||
|
||||
viper.SetDefault("PASSWORD_CHECK_LENGTH", true)
|
||||
viper.SetDefault("SMTP_ENABLED", false)
|
||||
viper.SetDefault("SMTP_USE_TLS", false)
|
||||
viper.SetDefault("SMTP_USE_SSL", false)
|
||||
viper.SetDefault("SMTP_FROM", "An Easywish instance")
|
||||
|
||||
viper.SetDefault("PASSWORD_MIN_LENGTH", 6)
|
||||
viper.SetDefault("PASSWORD_MAX_LENGTH", 100)
|
||||
viper.SetDefault("PASSWORD_CHECK_NUMBERS", false)
|
||||
viper.SetDefault("PASSWORD_CHECK_CHARACTERS", false)
|
||||
viper.SetDefault("PASSWORD_CHECK_CASES", false)
|
||||
viper.SetDefault("PASSWORD_CHECK_SYMBOLS", false)
|
||||
viper.SetDefault("PASSWORD_CHECK_LEAKED", false)
|
||||
@@ -89,8 +108,20 @@ func Load() (*Config, error) {
|
||||
viper.BindEnv("JWT_EXP_ACCESS")
|
||||
viper.BindEnv("JWT_EXP_REFRESH")
|
||||
|
||||
viper.BindEnv("PASSWORD_CHECK_LENGTH")
|
||||
viper.BindEnv("SMTP_ENABLED")
|
||||
viper.BindEnv("SMTP_SERVER")
|
||||
viper.BindEnv("SMTP_PORT")
|
||||
viper.BindEnv("SMTP_USER")
|
||||
viper.BindEnv("SMTP_PASSWORD")
|
||||
viper.BindEnv("SMTP_FROM")
|
||||
viper.BindEnv("SMTP_USE_TLS")
|
||||
viper.BindEnv("SMTP_USE_SSL")
|
||||
viper.BindEnv("SMTP_TIMEOUT")
|
||||
|
||||
viper.BindEnv("PASSWORD_MIN_LENGTH")
|
||||
viper.BindEnv("PASSWORD_MAX_LENGTH")
|
||||
viper.BindEnv("PASSWORD_CHECK_NUMBERS")
|
||||
viper.BindEnv("PASSWORD_CHECK_CHARACTERS")
|
||||
viper.BindEnv("PASSWORD_CHECK_CASES")
|
||||
viper.BindEnv("PASSWORD_CHECK_SYMBOLS")
|
||||
viper.BindEnv("PASSWORD_CHECK_LEAKED")
|
||||
@@ -122,7 +153,7 @@ func Load() (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
func GetConfig() Config {
|
||||
|
||||
if config == nil {
|
||||
|
||||
@@ -131,7 +162,7 @@ func GetConfig() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
return *config
|
||||
}
|
||||
|
||||
var config *Config
|
||||
|
||||
@@ -11,7 +11,7 @@ const docTemplate = `{
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"license": {
|
||||
"name": "GPL 3.0"
|
||||
"name": "GPL-3.0"
|
||||
},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
@@ -139,7 +139,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account is created and awaiting verification"
|
||||
},
|
||||
"409": {
|
||||
"description": "Username or email is already taken"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/registrationComplete": {
|
||||
@@ -154,7 +161,25 @@ const docTemplate = `{
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Confirm with code, finish creating the account",
|
||||
"responses": {}
|
||||
"parameters": [
|
||||
{
|
||||
"description": "desc",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.RegistrationCompleteRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "desc",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.RegistrationCompleteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/profile": {
|
||||
@@ -305,13 +330,16 @@ const docTemplate = `{
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
"totp": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 20,
|
||||
"minLength": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -337,13 +365,43 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "TODO: password checking",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 20,
|
||||
"minLength": 3
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RegistrationCompleteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"username",
|
||||
"verification_code"
|
||||
],
|
||||
"properties": {
|
||||
"birthday": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"verification_code": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RegistrationCompleteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"title": "Easywish client API",
|
||||
"contact": {},
|
||||
"license": {
|
||||
"name": "GPL 3.0"
|
||||
"name": "GPL-3.0"
|
||||
},
|
||||
"version": "1.0"
|
||||
},
|
||||
@@ -135,7 +135,14 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account is created and awaiting verification"
|
||||
},
|
||||
"409": {
|
||||
"description": "Username or email is already taken"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/registrationComplete": {
|
||||
@@ -150,7 +157,25 @@
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Confirm with code, finish creating the account",
|
||||
"responses": {}
|
||||
"parameters": [
|
||||
{
|
||||
"description": "desc",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.RegistrationCompleteRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "desc",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.RegistrationCompleteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/profile": {
|
||||
@@ -301,13 +326,16 @@
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
"totp": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 20,
|
||||
"minLength": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -333,13 +361,43 @@
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "TODO: password checking",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 20,
|
||||
"minLength": 3
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RegistrationCompleteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"username",
|
||||
"verification_code"
|
||||
],
|
||||
"properties": {
|
||||
"birthday": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"verification_code": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RegistrationCompleteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ definitions:
|
||||
models.LoginRequest:
|
||||
properties:
|
||||
password:
|
||||
maxLength: 100
|
||||
type: string
|
||||
totp:
|
||||
type: string
|
||||
username:
|
||||
maxLength: 20
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- password
|
||||
@@ -29,21 +32,40 @@ definitions:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
description: 'TODO: password checking'
|
||||
type: string
|
||||
username:
|
||||
maxLength: 20
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
models.RegistrationCompleteRequest:
|
||||
properties:
|
||||
birthday:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
verification_code:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- username
|
||||
- verification_code
|
||||
type: object
|
||||
models.RegistrationCompleteResponse:
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
refresh_token:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
description: Easy and feature-rich wishlist.
|
||||
license:
|
||||
name: GPL 3.0
|
||||
name: GPL-3.0
|
||||
title: Easywish client API
|
||||
version: "1.0"
|
||||
paths:
|
||||
@@ -124,7 +146,11 @@ paths:
|
||||
$ref: '#/definitions/models.RegistrationBeginRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: Account is created and awaiting verification
|
||||
"409":
|
||||
description: Username or email is already taken
|
||||
summary: Register an account
|
||||
tags:
|
||||
- Auth
|
||||
@@ -132,9 +158,20 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: desc
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.RegistrationCompleteRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
responses:
|
||||
"200":
|
||||
description: desc
|
||||
schema:
|
||||
$ref: '#/definitions/models.RegistrationCompleteResponse'
|
||||
summary: Confirm with code, finish creating the account
|
||||
tags:
|
||||
- Auth
|
||||
|
||||
@@ -4,10 +4,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/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
@@ -20,7 +20,6 @@ require (
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.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
|
||||
@@ -30,9 +29,9 @@ require (
|
||||
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-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
@@ -45,7 +44,6 @@ require (
|
||||
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/pmezard/go-difflib v1.0.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
|
||||
|
||||
@@ -37,8 +37,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
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=
|
||||
@@ -48,6 +48,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
errs "easywish/internal/errors"
|
||||
"easywish/internal/middleware"
|
||||
"easywish/internal/models"
|
||||
"easywish/internal/services"
|
||||
"easywish/internal/utils"
|
||||
"easywish/internal/utils/enums"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,7 +50,6 @@ func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController
|
||||
return &authControllerImpl{log: _log, authService: as}
|
||||
}
|
||||
|
||||
// Login implements AuthController.
|
||||
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
@@ -57,10 +58,27 @@ func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController
|
||||
// @Success 200 {object} models.LoginResponse "desc"
|
||||
// @Router /auth/login [post]
|
||||
func (a *authControllerImpl) Login(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
request, ok := utils.GetRequest[models.LoginRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.authService.Login(request.Body)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrForbidden) {
|
||||
c.Status(http.StatusForbidden)
|
||||
} else {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
// PasswordResetBegin implements AuthController.
|
||||
// @Summary Request password reset email
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
@@ -70,7 +88,6 @@ func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// PasswordResetComplete implements AuthController.
|
||||
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
@@ -80,7 +97,6 @@ func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Refresh implements AuthController.
|
||||
// @Summary Receive new tokens via refresh token
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
@@ -90,12 +106,13 @@ func (a *authControllerImpl) Refresh(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// RegistrationComplete implements AuthController.
|
||||
// @Summary Register an account
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.RegistrationBeginRequest true "desc"
|
||||
// @Success 200 "Account is created and awaiting verification"
|
||||
// @Success 409 "Username or email is already taken"
|
||||
// @Router /auth/registrationBegin [post]
|
||||
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
||||
|
||||
@@ -108,29 +125,53 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
||||
_, err := a.authService.RegistrationBegin(request.Body)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, err.Error())
|
||||
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
|
||||
c.Status(http.StatusConflict)
|
||||
} else {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusAccepted)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// RegistrationBegin implements AuthController.
|
||||
// @Summary Confirm with code, finish creating the account
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.RegistrationCompleteRequest true "desc"
|
||||
// @Success 200 {object} models.RegistrationCompleteResponse "desc"
|
||||
// @Router /auth/registrationComplete [post]
|
||||
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := a.authService.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", a.RegistrationComplete)
|
||||
group.POST("/login", a.Login)
|
||||
group.POST("/refresh", a.Refresh)
|
||||
group.POST("/passwordResetBegin", a.PasswordResetBegin)
|
||||
group.POST("/passwordResetComplete", a.PasswordResetComplete)
|
||||
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.RegistrationBeginRequest](enums.UserRole), a.Refresh)
|
||||
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin)
|
||||
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete)
|
||||
}
|
||||
|
||||
@@ -46,17 +46,17 @@ func (q *Queries) CreateBannedUser(ctx context.Context, arg CreateBannedUserPara
|
||||
|
||||
const createConfirmationCode = `-- name: CreateConfirmationCode :one
|
||||
INSERT INTO confirmation_codes(user_id, code_type, code_hash)
|
||||
VALUES ($1, $2, crypt($3::text, gen_salt('bf'))) RETURNING id, user_id, code_type, code_hash, expires_at, used, deleted
|
||||
VALUES ($1, $2, $3) RETURNING id, user_id, code_type, code_hash, expires_at, used, deleted
|
||||
`
|
||||
|
||||
type CreateConfirmationCodeParams struct {
|
||||
UserID int64
|
||||
CodeType int32
|
||||
Code string
|
||||
CodeHash string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateConfirmationCode(ctx context.Context, arg CreateConfirmationCodeParams) (ConfirmationCode, error) {
|
||||
row := q.db.QueryRow(ctx, createConfirmationCode, arg.UserID, arg.CodeType, arg.Code)
|
||||
row := q.db.QueryRow(ctx, createConfirmationCode, arg.UserID, arg.CodeType, arg.CodeHash)
|
||||
var i ConfirmationCode
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -72,17 +72,17 @@ func (q *Queries) CreateConfirmationCode(ctx context.Context, arg CreateConfirma
|
||||
|
||||
const createLoginInformation = `-- name: CreateLoginInformation :one
|
||||
INSERT INTO login_informations(user_id, email, password_hash)
|
||||
VALUES ( $1, $2, crypt($3::text, gen_salt('bf')) ) RETURNING id, user_id, email, password_hash, totp_encrypted, email_2fa_enabled, password_change_date
|
||||
VALUES ( $1, $2, $3::text ) RETURNING id, user_id, email, password_hash, totp_encrypted, email_2fa_enabled, password_change_date
|
||||
`
|
||||
|
||||
type CreateLoginInformationParams struct {
|
||||
UserID int64
|
||||
Email *string
|
||||
Password string
|
||||
UserID int64
|
||||
Email *string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateLoginInformation(ctx context.Context, arg CreateLoginInformationParams) (LoginInformation, error) {
|
||||
row := q.db.QueryRow(ctx, createLoginInformation, arg.UserID, arg.Email, arg.Password)
|
||||
row := q.db.QueryRow(ctx, createLoginInformation, arg.UserID, arg.Email, arg.PasswordHash)
|
||||
var i LoginInformation
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -229,32 +229,6 @@ func (q *Queries) DeleteUserByUsername(ctx context.Context, username string) err
|
||||
return err
|
||||
}
|
||||
|
||||
const getConfirmationCodeByCode = `-- name: GetConfirmationCodeByCode :one
|
||||
SELECT id, user_id, code_type, code_hash, expires_at, used, deleted FROM confirmation_codes
|
||||
WHERE user_id = $1 AND code_type = $2 AND expires_at > CURRENT_TIMESTAMP AND code_hash = crypt($3, code_hash)
|
||||
`
|
||||
|
||||
type GetConfirmationCodeByCodeParams struct {
|
||||
UserID int64
|
||||
CodeType int32
|
||||
Crypt string
|
||||
}
|
||||
|
||||
func (q *Queries) GetConfirmationCodeByCode(ctx context.Context, arg GetConfirmationCodeByCodeParams) (ConfirmationCode, error) {
|
||||
row := q.db.QueryRow(ctx, getConfirmationCodeByCode, arg.UserID, arg.CodeType, arg.Crypt)
|
||||
var i ConfirmationCode
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CodeType,
|
||||
&i.CodeHash,
|
||||
&i.ExpiresAt,
|
||||
&i.Used,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLoginInformationByUsername = `-- name: GetLoginInformationByUsername :one
|
||||
SELECT login_informations.id, login_informations.user_id, login_informations.email, login_informations.password_hash, login_informations.totp_encrypted, login_informations.email_2fa_enabled, login_informations.password_change_date FROM login_informations
|
||||
JOIN users ON users.id = login_informations.user_id
|
||||
@@ -526,43 +500,21 @@ func (q *Queries) GetUserBansByUsername(ctx context.Context, username string) ([
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserByLoginCredentials = `-- name: GetUserByLoginCredentials :one
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
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
|
||||
linfo.password_hash = crypt($2::text, linfo.password_hash)
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT users.id, users.username, users.verified, users.registration_date, users.deleted FROM users
|
||||
JOIN login_informations linfo ON linfo.user_id = users.id
|
||||
WHERE linfo.email = $1::text
|
||||
`
|
||||
|
||||
type GetUserByLoginCredentialsParams struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type GetUserByLoginCredentialsRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
TotpEncrypted *string
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByLoginCredentials(ctx context.Context, arg GetUserByLoginCredentialsParams) (GetUserByLoginCredentialsRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByLoginCredentials, arg.Username, arg.Password)
|
||||
var i GetUserByLoginCredentialsRow
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.TotpEncrypted,
|
||||
&i.Verified,
|
||||
&i.RegistrationDate,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -620,6 +572,78 @@ func (q *Queries) GetUserSessions(ctx context.Context, userID int64) ([]Session,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getValidConfirmationCodeByCode = `-- name: GetValidConfirmationCodeByCode :one
|
||||
SELECT id, user_id, code_type, code_hash, expires_at, used, deleted FROM confirmation_codes
|
||||
WHERE
|
||||
user_id = $1 AND
|
||||
code_type = $2 AND
|
||||
expires_at > CURRENT_TIMESTAMP AND
|
||||
used IS FALSE AND
|
||||
code_hash = crypt($3::text, code_hash)
|
||||
`
|
||||
|
||||
type GetValidConfirmationCodeByCodeParams struct {
|
||||
UserID int64
|
||||
CodeType int32
|
||||
Code string
|
||||
}
|
||||
|
||||
func (q *Queries) GetValidConfirmationCodeByCode(ctx context.Context, arg GetValidConfirmationCodeByCodeParams) (ConfirmationCode, error) {
|
||||
row := q.db.QueryRow(ctx, getValidConfirmationCodeByCode, arg.UserID, arg.CodeType, arg.Code)
|
||||
var i ConfirmationCode
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CodeType,
|
||||
&i.CodeHash,
|
||||
&i.ExpiresAt,
|
||||
&i.Used,
|
||||
&i.Deleted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getValidUserByLoginCredentials = `-- name: GetValidUserByLoginCredentials :one
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
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
|
||||
linfo.password_hash = crypt($2::text, linfo.password_hash)
|
||||
`
|
||||
|
||||
type GetValidUserByLoginCredentialsParams struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type GetValidUserByLoginCredentialsRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
TotpEncrypted *string
|
||||
}
|
||||
|
||||
func (q *Queries) GetValidUserByLoginCredentials(ctx context.Context, arg GetValidUserByLoginCredentialsParams) (GetValidUserByLoginCredentialsRow, error) {
|
||||
row := q.db.QueryRow(ctx, getValidUserByLoginCredentials, arg.Username, arg.Password)
|
||||
var i GetValidUserByLoginCredentialsRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.TotpEncrypted,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
|
||||
DELETE FROM confirmation_codes
|
||||
WHERE expires_at < CURRENT_TIMESTAMP
|
||||
@@ -640,9 +664,26 @@ func (q *Queries) PruneTerminatedSessions(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const terminateAllSessionsForUserByUsername = `-- name: TerminateAllSessionsForUserByUsername :exec
|
||||
UPDATE sessions
|
||||
SET terminated = TRUE
|
||||
FROM users
|
||||
WHERE sessions.user_id = users.id AND users.username = $1::text
|
||||
`
|
||||
|
||||
func (q *Queries) TerminateAllSessionsForUserByUsername(ctx context.Context, username string) error {
|
||||
_, err := q.db.Exec(ctx, terminateAllSessionsForUserByUsername, username)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateBannedUser = `-- name: UpdateBannedUser :exec
|
||||
UPDATE banned_users
|
||||
SET reason = $2, expires_at = $3, banned_by = $4, pardoned = $5, pardoned_by = $6
|
||||
SET
|
||||
reason = COALESCE($2, reason),
|
||||
expires_at = COALESCE($3, expires_at),
|
||||
banned_by = COALESCE($4, banned_by),
|
||||
pardoned = COALESCE($5, pardoned),
|
||||
pardoned_by = COALESCE($6, pardoned_by)
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -669,7 +710,9 @@ func (q *Queries) UpdateBannedUser(ctx context.Context, arg UpdateBannedUserPara
|
||||
|
||||
const updateConfirmationCode = `-- name: UpdateConfirmationCode :exec
|
||||
UPDATE confirmation_codes
|
||||
SET used = $2, deleted = $3
|
||||
SET
|
||||
used = COALESCE($2, used),
|
||||
deleted = COALESCE($3, deleted)
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -686,7 +729,18 @@ func (q *Queries) UpdateConfirmationCode(ctx context.Context, arg UpdateConfirma
|
||||
|
||||
const updateLoginInformationByUsername = `-- name: UpdateLoginInformationByUsername :exec
|
||||
UPDATE login_informations
|
||||
SET email = $2, password_hash = crypt($3::text, gen_salt('bf')), totp_encrypted = $4, email_2fa_enabled = $5, password_change_date = $6
|
||||
SET
|
||||
email = COALESCE($2, email),
|
||||
password_hash = COALESCE(
|
||||
CASE
|
||||
WHEN $3::text IS NOT NULL
|
||||
THEN crypt($3::text, gen_salt('bf'))
|
||||
END,
|
||||
password_hash
|
||||
),
|
||||
totp_encrypted = COALESCE($4, totp_encrypted),
|
||||
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
|
||||
password_change_date = COALESCE($6, password_change_date)
|
||||
FROM users
|
||||
WHERE users.username = $1 AND login_informations.user_id = users.id
|
||||
`
|
||||
@@ -714,7 +768,13 @@ func (q *Queries) UpdateLoginInformationByUsername(ctx context.Context, arg Upda
|
||||
|
||||
const updateProfileByUsername = `-- name: UpdateProfileByUsername :exec
|
||||
UPDATE profiles
|
||||
SET name = $2, bio = $3, birthday = $4, avatar_url = $5, color = $6, color_grad = $7
|
||||
SET
|
||||
name = COALESCE($2, name),
|
||||
bio = COALESCE($3, bio),
|
||||
birthday = COALESCE($4, birthday),
|
||||
avatar_url = COALESCE($5, avatar_url),
|
||||
color = COALESCE($6, color),
|
||||
color_grad = COALESCE($7, color_grad)
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
@@ -745,13 +805,13 @@ func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfile
|
||||
const updateProfileSettings = `-- name: UpdateProfileSettings :exec
|
||||
UPDATE profile_settings
|
||||
SET
|
||||
hide_fulfilled = $2,
|
||||
hide_profile_details = $3,
|
||||
hide_for_unauthenticated = $4,
|
||||
hide_birthday = $5,
|
||||
hide_dates = $6,
|
||||
captcha = $7,
|
||||
followers_only_interaction = $8
|
||||
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
|
||||
`
|
||||
|
||||
@@ -782,7 +842,13 @@ func (q *Queries) UpdateProfileSettings(ctx context.Context, arg UpdateProfileSe
|
||||
|
||||
const updateSession = `-- name: UpdateSession :exec
|
||||
UPDATE sessions
|
||||
SET name = $2, platform = $3, latest_ip = $4, login_time = $5, last_seen_date = $6, terminated = $7
|
||||
SET
|
||||
name = COALESCE($2, name),
|
||||
platform = COALESCE($3, platform),
|
||||
latest_ip = COALESCE($4, latest_ip),
|
||||
login_time = COALESCE($5, login_time),
|
||||
last_seen_date = COALESCE($6, last_seen_date),
|
||||
terminated = COALESCE($7, terminated)
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -811,7 +877,9 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er
|
||||
|
||||
const updateUser = `-- name: UpdateUser :exec
|
||||
UPDATE users
|
||||
SET verified = $2, deleted = $3
|
||||
SET
|
||||
verified = COALESCE($2, verified),
|
||||
deleted = COALESCE($3, deleted)
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
var (
|
||||
ErrUnauthorized = errors.New("User is not authorized")
|
||||
ErrUsernameTaken = errors.New("Provided username is already in use")
|
||||
ErrEmailTaken = errors.New("Provided email is already in use")
|
||||
ErrUserNotFound = errors.New("User was not found")
|
||||
ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code")
|
||||
ErrInvalidToken = errors.New("Token is invalid or expired")
|
||||
ErrServerError = errors.New("Internal server error")
|
||||
|
||||
@@ -24,4 +24,5 @@ import (
|
||||
var (
|
||||
ErrNotImplemented = errors.New("Feature is not implemented")
|
||||
ErrBadRequest = errors.New("Bad request")
|
||||
ErrForbidden = errors.New("Access is denied")
|
||||
)
|
||||
|
||||
34
backend/internal/errors/postgres.go
Normal file
34
backend/internal/errors/postgres.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 errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func GetPgError(err error) string {
|
||||
var pgErr *pgconn.PgError
|
||||
errors.As(err, &pgErr)
|
||||
return pgErr.Code
|
||||
}
|
||||
|
||||
func MatchPgError(err error, code string) bool {
|
||||
return GetPgError(err) == code
|
||||
}
|
||||
10
backend/internal/errors/smtp.go
Normal file
10
backend/internal/errors/smtp.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSmtpDisabled = errors.New("Smtp is not enabled in the config")
|
||||
ErrSmtpMissingConfiguration = errors.New("Some necessary SMTP configuration is missing")
|
||||
)
|
||||
@@ -34,6 +34,8 @@ type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// TODO: validate token type
|
||||
// TODO: validate session guid
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
@@ -19,9 +19,12 @@ package middleware
|
||||
|
||||
import (
|
||||
"easywish/internal/utils/enums"
|
||||
"easywish/internal/validation"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
@@ -43,15 +46,15 @@ func UserInfoFromContext(c *gin.Context) (*UserInfo, bool) {
|
||||
var ok bool
|
||||
|
||||
username, ok = c.Get("username") ; if !ok {
|
||||
return &UserInfo{Username: "", Role: enums.GuestRole}, true
|
||||
return &UserInfo{Username: "", Role: enums.GuestRole}, true
|
||||
}
|
||||
|
||||
role, ok = c.Get("role"); if !ok {
|
||||
return nil, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if username == nil {
|
||||
return &UserInfo{Username: "", Role: enums.GuestRole}, true
|
||||
return &UserInfo{Username: "", Role: enums.GuestRole}, true
|
||||
}
|
||||
|
||||
if role == nil {
|
||||
@@ -77,13 +80,22 @@ func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
|
||||
|
||||
if userInfo.Role < role {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var body T
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
|
||||
// TODO: implement automatic validation here
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,28 +23,25 @@ type Tokens struct {
|
||||
}
|
||||
|
||||
type RegistrationBeginRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=20"`
|
||||
Email *string `json:"email" binding:"email"`
|
||||
Password string `json:"password" binding:"required"` // TODO: password checking
|
||||
Username string `json:"username" binding:"required" validate:"username"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required" validate:"password"`
|
||||
}
|
||||
|
||||
// TODO: length check
|
||||
type RegistrationCompleteRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Username string `json:"username" binding:"required" validate:"username"`
|
||||
VerificationCode string `json:"verification_code" binding:"required"`
|
||||
Name string `json:"name" binding:"required,max=75"`
|
||||
Name string `json:"name" binding:"required" validate:"name"`
|
||||
Birthday *string `json:"birthday"`
|
||||
AvatarUrl *string `json:"avatar_url" binding:"http_url,max=255"`
|
||||
}
|
||||
|
||||
type RegistrationCompleteResponse struct {
|
||||
Tokens
|
||||
}
|
||||
|
||||
// TODO: length check
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username" binding:"required,min=3,max=20" validate:"username"`
|
||||
Password string `json:"password" binding:"required,max=100"`
|
||||
TOTP *string `json:"totp"`
|
||||
}
|
||||
|
||||
|
||||
@@ -18,53 +18,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"easywish/config"
|
||||
"easywish/internal/database"
|
||||
errs "easywish/internal/errors"
|
||||
"easywish/internal/models"
|
||||
"easywish/internal/utils"
|
||||
"easywish/internal/utils/enums"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
RegistrationBegin(request models.RegistrationBeginRequest) (bool, error)
|
||||
RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error)
|
||||
RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error)
|
||||
Login(request models.LoginRequest) (*models.LoginResponse, error)
|
||||
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
|
||||
}
|
||||
|
||||
type authServiceImpl struct {
|
||||
log *zap.Logger
|
||||
smtp SmtpService
|
||||
dbctx database.DbContext
|
||||
}
|
||||
|
||||
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext) AuthService {
|
||||
return &authServiceImpl{log: _log, dbctx: _dbctx}
|
||||
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _smtp SmtpService) AuthService {
|
||||
return &authServiceImpl{log: _log, dbctx: _dbctx, smtp: _smtp}
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) {
|
||||
|
||||
var user database.User
|
||||
var generatedCode string
|
||||
var generatedCodeHash string
|
||||
var passwordHash string
|
||||
|
||||
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
|
||||
defer helper.Rollback()
|
||||
|
||||
var err error
|
||||
|
||||
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { // TODO: validation
|
||||
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil {
|
||||
|
||||
if errs.MatchPgError(err, pgerrcode.UniqueViolation) {
|
||||
a.log.Warn(
|
||||
"Attempted registration for a taken username",
|
||||
zap.String("username", request.Username),
|
||||
zap.Error(err))
|
||||
|
||||
return false, errs.ErrUsernameTaken
|
||||
}
|
||||
|
||||
a.log.Error("Failed to add user to database", zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
a.log.Info("Registraion of a new user", zap.String("username", user.Username), zap.Int64("id", user.ID))
|
||||
if passwordHash, err = utils.HashPassword(request.Password); err != nil {
|
||||
a.log.Error("Error on hashing password", zap.Error(err))
|
||||
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{
|
||||
UserID: user.ID,
|
||||
Email: request.Email,
|
||||
Password: request.Password, // Hashed in database
|
||||
Email: utils.NewPointer(request.Email),
|
||||
PasswordHash: passwordHash, // Hashed in database
|
||||
}); err != nil {
|
||||
|
||||
if errs.MatchPgError(err, pgerrcode.UniqueViolation) {
|
||||
// Since we've already checked for username previously, only email is left
|
||||
return false, errs.ErrEmailTaken
|
||||
}
|
||||
|
||||
a.log.Error("Failed to add login information for user to database", zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
@@ -73,52 +101,268 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
|
||||
a.log.Error("Failed to generate a registration code", zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
if generatedCodeHash, err = utils.HashPassword(generatedCode); err != nil {
|
||||
a.log.Error("Failed to hash generated verification code", zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
|
||||
UserID: user.ID,
|
||||
CodeType: int32(enums.RegistrationCodeType),
|
||||
Code: generatedCode, // Hashed in database
|
||||
CodeHash: generatedCodeHash, // Hashed in database
|
||||
}); err != nil {
|
||||
a.log.Error("Failed to add registration code to database", zap.Error(err))
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
a.log.Info("Registered a new user", zap.String("username", user.Username))
|
||||
a.log.Info(
|
||||
"Registered a new user",
|
||||
zap.String("username", user.Username),
|
||||
zap.Int64("id", user.ID))
|
||||
|
||||
if config.GetConfig().SmtpEnabled {
|
||||
|
||||
if err := a.smtp.SendEmail(
|
||||
request.Email,
|
||||
"Easywish",
|
||||
fmt.Sprintf("Your registration code is %s", generatedCode),
|
||||
); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to send registration email",
|
||||
zap.String("email", request.Email),
|
||||
zap.String("username", request.Username),
|
||||
zap.Error(err))
|
||||
|
||||
return false, errs.ErrServerError
|
||||
}
|
||||
|
||||
} else {
|
||||
a.log.Debug(
|
||||
"Declated registration code for a new user. Enable SMTP in the config to disable this message",
|
||||
zap.String("username", user.Username),
|
||||
zap.String("code", generatedCode))
|
||||
}
|
||||
|
||||
helper.Commit()
|
||||
|
||||
a.log.Debug("Declated registration code for a new user", zap.String("username", user.Username), zap.String("code", generatedCode))
|
||||
|
||||
// TODO: Send verification email
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error) {
|
||||
return nil, errs.ErrNotImplemented
|
||||
func (a *authServiceImpl) RegistrationComplete(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
|
||||
|
||||
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
|
||||
defer helper.Rollback()
|
||||
|
||||
user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
a.log.Warn(
|
||||
"Could not find user attempting to complete registration with given username",
|
||||
zap.String("username", request.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
a.log.Error(
|
||||
"Failed to get user",
|
||||
zap.String("username", request.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
confirmationCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{
|
||||
UserID: user.ID,
|
||||
CodeType: int32(enums.RegistrationCodeType),
|
||||
Code: request.VerificationCode,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
a.log.Warn(
|
||||
"User supplied unexistent confirmation code for completing registration",
|
||||
zap.String("username", user.Username),
|
||||
zap.String("code", request.VerificationCode),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrForbidden
|
||||
}
|
||||
|
||||
a.log.Error(
|
||||
"Failed to acquire specified registration code",
|
||||
zap.String("username", user.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
|
||||
ID: confirmationCode.ID,
|
||||
Used: utils.NewPointer(true),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to update the user's registration code used state",
|
||||
zap.String("username", user.Username),
|
||||
zap.Int64("confirmation_code_id", confirmationCode.ID),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
|
||||
ID: user.ID,
|
||||
Verified: utils.NewPointer(true),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Error("Failed to update verified status for user",
|
||||
zap.String("username", user.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{
|
||||
UserID: user.ID,
|
||||
Name: request.Name,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.log.Error("Failed to create profile for user",
|
||||
zap.String("username", user.Username),
|
||||
|
||||
)
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
_, err = db.TXQueries.CreateProfileSettings(db.CTX, profile.ID)
|
||||
|
||||
if err != nil {
|
||||
a.log.Error("Failed to create profile settings for user",
|
||||
zap.String("username", user.Username),
|
||||
zap.Error(err))
|
||||
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))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String())
|
||||
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to create tokens for newly registered user, rolling back registration",
|
||||
zap.String("username", user.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
helper.Commit()
|
||||
|
||||
a.log.Info(
|
||||
"User verified registration",
|
||||
zap.String("username", request.Username))
|
||||
|
||||
response := models.RegistrationCompleteResponse{Tokens: models.Tokens{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
}}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// TODO: totp
|
||||
func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) {
|
||||
conn, ctx, err := utils.GetDbConn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
var userRow database.GetValidUserByLoginCredentialsRow
|
||||
var session database.Session
|
||||
|
||||
queries := database.New(conn)
|
||||
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
|
||||
defer helper.Rollback()
|
||||
|
||||
user, err := queries.GetUserByLoginCredentials(ctx, database.GetUserByLoginCredentialsParams{
|
||||
var err error
|
||||
|
||||
userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{
|
||||
Username: request.Username,
|
||||
Password: request.Password,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrUnauthorized
|
||||
a.log.Warn(
|
||||
"Failed login attempt",
|
||||
zap.Error(err))
|
||||
|
||||
var returnedError error
|
||||
|
||||
switch err {
|
||||
case pgx.ErrNoRows:
|
||||
returnedError = errs.ErrForbidden
|
||||
default:
|
||||
returnedError = errs.ErrServerError
|
||||
}
|
||||
|
||||
return nil, returnedError
|
||||
}
|
||||
|
||||
accessToken, refreshToken, err := utils.GenerateTokens(user.Username)
|
||||
// Until release 4, only 1 session at a time is supported
|
||||
if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, request.Username); err != nil {
|
||||
a.log.Error(
|
||||
"Failed to terminate older sessions for user trying to log in",
|
||||
zap.String("username", request.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
return &models.LoginResponse{Tokens: models.Tokens{AccessToken: accessToken, RefreshToken: refreshToken}}, nil
|
||||
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))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
accessToken, refreshToken, err := utils.GenerateTokens(userRow.Username, session.Guid.String())
|
||||
if err != nil {
|
||||
a.log.Error(
|
||||
"Failed to generate tokens for a new login",
|
||||
zap.String("username", userRow.Username),
|
||||
zap.Error(err))
|
||||
return nil, errs.ErrServerError
|
||||
}
|
||||
|
||||
helper.Commit()
|
||||
|
||||
response := models.LoginResponse{Tokens: models.Tokens{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
}}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
var Module = fx.Module("services",
|
||||
fx.Provide(
|
||||
NewSmtpService,
|
||||
NewAuthService,
|
||||
),
|
||||
)
|
||||
|
||||
143
backend/internal/services/smtp.go
Normal file
143
backend/internal/services/smtp.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"easywish/config"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
errs "easywish/internal/errors"
|
||||
)
|
||||
|
||||
type SmtpService interface {
|
||||
SendEmail(to string, subject, body string) error
|
||||
}
|
||||
|
||||
type smtpServiceImpl struct {
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewSmtpService(_log *zap.Logger) SmtpService {
|
||||
return &smtpServiceImpl{log: _log}
|
||||
}
|
||||
|
||||
func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
if !cfg.SmtpEnabled {
|
||||
s.log.Error("Attempted to send an email with SMTP disabled in the config")
|
||||
return errs.ErrSmtpDisabled
|
||||
}
|
||||
|
||||
if cfg.SmtpServer == "" || cfg.SmtpPort == 0 || cfg.SmtpFrom == "" {
|
||||
s.log.Error("SMTP service settings or the SMTP From paramater are not set")
|
||||
return errs.ErrSmtpMissingConfiguration
|
||||
}
|
||||
|
||||
toSlice := []string{to}
|
||||
|
||||
headers := map[string]string{
|
||||
"From": cfg.SmtpFrom,
|
||||
"To": strings.Join(toSlice, ", "),
|
||||
"Subject": subject,
|
||||
"MIME-Version": "1.0",
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for k, v := range headers {
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
sb.WriteString("\r\n" + body)
|
||||
message := []byte(sb.String())
|
||||
|
||||
hostPort := fmt.Sprintf("%s:%d", cfg.SmtpServer, cfg.SmtpPort)
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
if cfg.SmtpUseSSL {
|
||||
tlsConfig := &tls.Config{ServerName: cfg.SmtpServer}
|
||||
conn, err = tls.Dial("tcp", hostPort, tlsConfig)
|
||||
} else {
|
||||
timeout := time.Duration(cfg.SmtpTimeout) * time.Second
|
||||
conn, err = net.DialTimeout("tcp", hostPort, timeout)
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("SMTP connection failure", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.SmtpServer)
|
||||
if err != nil {
|
||||
s.log.Error("SMTP client creation failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if !cfg.SmtpUseSSL && cfg.SmtpUseTLS {
|
||||
tlsConfig := &tls.Config{ServerName: cfg.SmtpServer}
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate if credentials exist
|
||||
if cfg.SmtpUser != "" && cfg.SmtpPassword != "" {
|
||||
auth := smtp.PlainAuth("", cfg.SmtpUser, cfg.SmtpPassword, cfg.SmtpServer)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
s.log.Error("SMTP authentication failure", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Mail(cfg.SmtpFrom); err != nil {
|
||||
s.log.Error("SMTP sender set failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
for _, recipient := range to {
|
||||
if err = client.Rcpt(string(recipient)); err != nil {
|
||||
s.log.Error("SMTP recipient set failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Send email body
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
s.log.Error("SMTP data command failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(message); err != nil {
|
||||
s.log.Error("SMTP message write failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
s.log.Error("SMTP message close failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
37
backend/internal/utils/codes.go
Normal file
37
backend/internal/utils/codes.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func GenerateSecure6DigitNumber() (string, error) {
|
||||
maxNumber := 1000000
|
||||
b := make([]byte, 4)
|
||||
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
|
||||
num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % maxNumber
|
||||
|
||||
return fmt.Sprintf("%06d", num), nil
|
||||
}
|
||||
@@ -29,3 +29,9 @@ const (
|
||||
UserRole
|
||||
AdminRole
|
||||
)
|
||||
|
||||
type JwtTokenType int32
|
||||
const (
|
||||
JwtAccessTokenType JwtTokenType = iota
|
||||
JwtRefreshTokenType
|
||||
)
|
||||
|
||||
31
backend/internal/utils/hash.go
Normal file
31
backend/internal/utils/hash.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package utils
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -19,22 +19,27 @@ package utils
|
||||
|
||||
import (
|
||||
"easywish/config"
|
||||
"easywish/internal/utils/enums"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func GenerateTokens(username string) (accessToken, refreshToken string, err error) {
|
||||
func GenerateTokens(username string, sessionGuid string) (accessToken, refreshToken string, err error) {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
accessClaims := jwt.MapClaims{
|
||||
"username": username,
|
||||
"guid": sessionGuid,
|
||||
"type": enums.JwtAccessTokenType,
|
||||
"exp": time.Now().Add(time.Minute * time.Duration(cfg.JwtExpAccess)).Unix(),
|
||||
}
|
||||
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.JwtSecret))
|
||||
|
||||
refreshClaims := jwt.MapClaims{
|
||||
"username": username,
|
||||
"guid": sessionGuid,
|
||||
"type": enums.JwtRefreshTokenType,
|
||||
"exp": time.Now().Add(time.Hour * time.Duration(cfg.JwtExpRefresh)).Unix(),
|
||||
}
|
||||
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.JwtSecret))
|
||||
|
||||
22
backend/internal/utils/pointer.go
Normal file
22
backend/internal/utils/pointer.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 utils
|
||||
|
||||
func NewPointer[T any](d T) *T {
|
||||
return &d
|
||||
}
|
||||
@@ -1,80 +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 utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"easywish/config"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func GenerateSecure6DigitNumber() (string, error) {
|
||||
// Generate a random number between 0 and 999999 (inclusive)
|
||||
// This ensures we get a 6-digit number, including those starting with 0
|
||||
max := 1000000 // Upper bound (exclusive)
|
||||
b := make([]byte, 4) // A 4-byte slice is sufficient for a 32-bit integer
|
||||
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Convert bytes to an integer
|
||||
// We use a simple modulo operation to get a number within our desired range.
|
||||
// While this introduces a slight bias for very large ranges, for 1,000,000
|
||||
// it's negligible and simpler than more complex methods like rejection sampling.
|
||||
num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % max
|
||||
|
||||
return fmt.Sprintf("%06d", num), nil
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
if cfg.PasswordCheckLength {
|
||||
passwordLength := len(password); if passwordLength < 8 || passwordLength > 100 {
|
||||
return errors.New("Password must be between 8 and 100 characters")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckNumbers {
|
||||
numbersPresent := regexp.MustCompile(`[0-9]`).MatchString(password); if !numbersPresent {
|
||||
return errors.New("Password must contain at least 1 number")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckCases {
|
||||
differentCasesPresent := regexp.MustCompile(`(?=.*[a-z])(?=.*[A-Z])`).MatchString(password); if !differentCasesPresent {
|
||||
return errors.New("Password must have uppercase and lowercase characters")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckSymbols {
|
||||
symbolsPresent := regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password); if !symbolsPresent {
|
||||
return errors.New("Password must contain at least one special symbol")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckLeaked {
|
||||
// TODO: implement checking leaked passwords via rockme.txt
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
backend/internal/validation/custom.go
Normal file
83
backend/internal/validation/custom.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"easywish/config"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type CustomValidatorHandler struct {
|
||||
Function func(fl validator.FieldLevel) bool
|
||||
FieldName string
|
||||
}
|
||||
|
||||
func GetCustomHandlers() []CustomValidatorHandler {
|
||||
|
||||
handlers := []CustomValidatorHandler{
|
||||
|
||||
{
|
||||
FieldName: "username",
|
||||
Function: func(fl validator.FieldLevel) bool {
|
||||
username := fl.Field().String()
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username)
|
||||
}},
|
||||
|
||||
{
|
||||
FieldName: "name",
|
||||
Function: func(fl validator.FieldLevel) bool {
|
||||
username := fl.Field().String()
|
||||
return regexp.MustCompile(`^.{1,75}$`).MatchString(username)
|
||||
}},
|
||||
|
||||
{
|
||||
FieldName: "password",
|
||||
Function: func(fl validator.FieldLevel) bool {
|
||||
password := fl.Field().String()
|
||||
cfg := config.GetConfig()
|
||||
|
||||
if cfg.PasswordMaxLength < len(password) || len(password) < cfg.PasswordMinLength {
|
||||
return false
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckNumbers && !regexp.MustCompile(`\d+`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckCases && !regexp.MustCompile(`^(?=.*[a-z])(?=.*[A-Z]).*$`).MatchString(password) ||
|
||||
cfg.PasswordCheckCharacters && !regexp.MustCompile(`[a-zA-Z]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckSymbols && !regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
if cfg.PasswordCheckLeaked {
|
||||
// TODO: implement rockme check
|
||||
}
|
||||
|
||||
return true
|
||||
}},
|
||||
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
28
backend/internal/validation/setup.go
Normal file
28
backend/internal/validation/setup.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
var Module = fx.Module("validation",
|
||||
fx.Provide(
|
||||
NewValidator,
|
||||
),
|
||||
)
|
||||
32
backend/internal/validation/validator.go
Normal file
32
backend/internal/validation/validator.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2025 Nikolai Papin
|
||||
//
|
||||
// This file is part of Easywish
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
// the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
func NewValidator() *validator.Validate {
|
||||
v := validator.New()
|
||||
|
||||
for _, handler := range GetCustomHandlers() {
|
||||
v.RegisterValidation(handler.FieldName, handler.Function)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
@@ -16,9 +16,34 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
environment:
|
||||
ENVIRONMENT: ${ENVIRONMENT}
|
||||
HOSTNAME: ${HOSTNAME}
|
||||
PORT: ${PORT}
|
||||
POSTGRES_URL: ${POSTGRES_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
MINIO_URL: ${MINIO_URL}
|
||||
JWT_ALGORITHM: ${JWT_ALGORITHM}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ISSUER: ${JWT_ISSUER}
|
||||
JWT_AUDIENCE: ${JWT_AUDIENCE}
|
||||
JWT_EXP_ACCESS: ${JWT_EXP_ACCESS}
|
||||
JWT_EXP_REFRESH: ${JWT_EXP_REFRESH}
|
||||
SMTP_ENABLED: ${SMTP_ENABLED}
|
||||
SMTP_SERVER: ${SMTP_SERVER}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: ${SMTP_FROM}
|
||||
SMTP_USE_TLS: ${SMTP_USE_TLS}
|
||||
SMTP_USE_SSL: ${SMTP_USE_SSL}
|
||||
SMTP_TIMEOUT: ${SMTP_TIMEOUT}
|
||||
PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH}
|
||||
PASSWORD_MAX_LENGTH: ${PASSWORD_MAX_LENGTH}
|
||||
PASSWORD_CHECK_NUMBER: ${PASSWORD_CHECK_NUMBER}
|
||||
PASSWORD_CHECK_CHARAC: ${PASSWORD_CHECK_CHARAC}
|
||||
PASSWORD_CHECK_CASES: ${PASSWORD_CHECK_CASES}
|
||||
PASSWORD_CHECK_SYMBOL: ${PASSWORD_CHECK_SYMBOL}
|
||||
PASSWORD_CHECK_LEAKED: ${PASSWORD_CHECK_LEAKED}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
|
||||
101
sqlc/query.sql
101
sqlc/query.sql
@@ -1,5 +1,22 @@
|
||||
-- vim:fileencoding=utf-8:foldmethod=marker
|
||||
|
||||
-- Copyright (c) 2025 Nikolai Papin
|
||||
--
|
||||
-- This file is part of Easywish
|
||||
--
|
||||
-- This program is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU General Public License as published by
|
||||
-- the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
-- the GNU General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
--: User Object {{{
|
||||
|
||||
;-- name: CreateUser :one
|
||||
@@ -8,7 +25,9 @@ VALUES ($1, false) RETURNING *;
|
||||
|
||||
;-- name: UpdateUser :exec
|
||||
UPDATE users
|
||||
SET verified = $2, deleted = $3
|
||||
SET
|
||||
verified = COALESCE($2, verified),
|
||||
deleted = COALESCE($3, deleted)
|
||||
WHERE id = $1;
|
||||
|
||||
;-- name: UpdateUserByUsername :exec
|
||||
@@ -32,7 +51,12 @@ WHERE id = $1;
|
||||
SELECT * FROM users
|
||||
WHERE username = $1;
|
||||
|
||||
;-- name: GetUserByLoginCredentials :one
|
||||
;-- name: GetUserByEmail :one
|
||||
SELECT users.* FROM users
|
||||
JOIN login_informations linfo ON linfo.user_id = users.id
|
||||
WHERE linfo.email = @email::text;
|
||||
|
||||
;-- name: GetValidUserByLoginCredentials :one
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
@@ -58,7 +82,12 @@ VALUES ( $1, $2, $3, $4) RETURNING *;
|
||||
|
||||
;-- name: UpdateBannedUser :exec
|
||||
UPDATE banned_users
|
||||
SET reason = $2, expires_at = $3, banned_by = $4, pardoned = $5, pardoned_by = $6
|
||||
SET
|
||||
reason = COALESCE($2, reason),
|
||||
expires_at = COALESCE($3, expires_at),
|
||||
banned_by = COALESCE($4, banned_by),
|
||||
pardoned = COALESCE($5, pardoned),
|
||||
pardoned_by = COALESCE($6, pardoned_by)
|
||||
WHERE id = $1;
|
||||
|
||||
;-- name: GetUserBans :many
|
||||
@@ -76,11 +105,22 @@ WHERE users.username = $1;
|
||||
|
||||
;-- name: CreateLoginInformation :one
|
||||
INSERT INTO login_informations(user_id, email, password_hash)
|
||||
VALUES ( $1, $2, crypt(@password::text, gen_salt('bf')) ) RETURNING *;
|
||||
VALUES ( $1, $2, @password_hash::text ) RETURNING *;
|
||||
|
||||
;-- name: UpdateLoginInformationByUsername :exec
|
||||
UPDATE login_informations
|
||||
SET email = $2, password_hash = crypt(@password::text, gen_salt('bf')), totp_encrypted = $4, email_2fa_enabled = $5, password_change_date = $6
|
||||
SET
|
||||
email = COALESCE($2, email),
|
||||
password_hash = COALESCE(
|
||||
CASE
|
||||
WHEN @password::text IS NOT NULL
|
||||
THEN crypt(@password::text, gen_salt('bf'))
|
||||
END,
|
||||
password_hash
|
||||
),
|
||||
totp_encrypted = COALESCE($4, totp_encrypted),
|
||||
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
|
||||
password_change_date = COALESCE($6, password_change_date)
|
||||
FROM users
|
||||
WHERE users.username = $1 AND login_informations.user_id = users.id;
|
||||
|
||||
@@ -95,15 +135,22 @@ WHERE users.username = $1;
|
||||
|
||||
;-- name: CreateConfirmationCode :one
|
||||
INSERT INTO confirmation_codes(user_id, code_type, code_hash)
|
||||
VALUES ($1, $2, crypt(@code::text, gen_salt('bf'))) RETURNING *;
|
||||
VALUES ($1, $2, @code_hash) RETURNING *;
|
||||
|
||||
;-- name: GetConfirmationCodeByCode :one
|
||||
;-- name: GetValidConfirmationCodeByCode :one
|
||||
SELECT * FROM confirmation_codes
|
||||
WHERE user_id = $1 AND code_type = $2 AND expires_at > CURRENT_TIMESTAMP AND code_hash = crypt($3, code_hash);
|
||||
WHERE
|
||||
user_id = $1 AND
|
||||
code_type = $2 AND
|
||||
expires_at > CURRENT_TIMESTAMP AND
|
||||
used IS FALSE AND
|
||||
code_hash = crypt(@code::text, code_hash);
|
||||
|
||||
;-- name: UpdateConfirmationCode :exec
|
||||
UPDATE confirmation_codes
|
||||
SET used = $2, deleted = $3
|
||||
SET
|
||||
used = COALESCE($2, used),
|
||||
deleted = COALESCE($3, deleted)
|
||||
WHERE id = $1;
|
||||
|
||||
;-- name: PruneExpiredConfirmationCodes :exec
|
||||
@@ -120,13 +167,25 @@ VALUES ($1, $2, $3, $4) RETURNING *;
|
||||
|
||||
;-- name: UpdateSession :exec
|
||||
UPDATE sessions
|
||||
SET name = $2, platform = $3, latest_ip = $4, login_time = $5, last_seen_date = $6, terminated = $7
|
||||
SET
|
||||
name = COALESCE($2, name),
|
||||
platform = COALESCE($3, platform),
|
||||
latest_ip = COALESCE($4, latest_ip),
|
||||
login_time = COALESCE($5, login_time),
|
||||
last_seen_date = COALESCE($6, last_seen_date),
|
||||
terminated = COALESCE($7, terminated)
|
||||
WHERE id = $1;
|
||||
|
||||
;-- name: GetUserSessions :many
|
||||
SELECT * FROM sessions
|
||||
WHERE user_id = $1 AND terminated IS FALSE;
|
||||
|
||||
;-- name: TerminateAllSessionsForUserByUsername :exec
|
||||
UPDATE sessions
|
||||
SET terminated = TRUE
|
||||
FROM users
|
||||
WHERE sessions.user_id = users.id AND users.username = @username::text;
|
||||
|
||||
;-- name: PruneTerminatedSessions :exec
|
||||
DELETE FROM sessions
|
||||
WHERE terminated IS TRUE;
|
||||
@@ -141,7 +200,13 @@ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *;
|
||||
|
||||
;-- name: UpdateProfileByUsername :exec
|
||||
UPDATE profiles
|
||||
SET name = $2, bio = $3, birthday = $4, avatar_url = $5, color = $6, color_grad = $7
|
||||
SET
|
||||
name = COALESCE($2, name),
|
||||
bio = COALESCE($3, bio),
|
||||
birthday = COALESCE($4, birthday),
|
||||
avatar_url = COALESCE($5, avatar_url),
|
||||
color = COALESCE($6, color),
|
||||
color_grad = COALESCE($7, color_grad)
|
||||
FROM users
|
||||
WHERE username = $1;
|
||||
|
||||
@@ -203,13 +268,13 @@ VALUES ($1) RETURNING *;
|
||||
;-- name: UpdateProfileSettings :exec
|
||||
UPDATE profile_settings
|
||||
SET
|
||||
hide_fulfilled = $2,
|
||||
hide_profile_details = $3,
|
||||
hide_for_unauthenticated = $4,
|
||||
hide_birthday = $5,
|
||||
hide_dates = $6,
|
||||
captcha = $7,
|
||||
followers_only_interaction = $8
|
||||
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;
|
||||
|
||||
;-- name: GetProfileSettingsByUsername :one
|
||||
|
||||
@@ -1,3 +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/>.
|
||||
|
||||
;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
@@ -41,7 +60,7 @@ CREATE TABLE IF NOT EXISTS "confirmation_codes" (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
guid UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100),
|
||||
platform VARCHAR(32),
|
||||
|
||||
Reference in New Issue
Block a user