36 Commits

Author SHA1 Message Date
a5f89c2b32 Merge branch 'ml2' into fix-auth_service 2025-08-04 21:22:22 +03:00
ffcbff5294 Merge pull request 'feat: implement wish list and wish features including creation, retrieval, and updates;' (#7) from feat-sql into ml2
Reviewed-on: #7
2025-08-04 21:19:00 +03:00
d7d18f1284 fix: corrected redis logic to prevent temporary lock-outs on failed database transactions;
fix: ChangePassword transaction isolation;
chore: highlighted issues
2025-08-04 21:17:06 +03:00
b1125d3f6a feat: implement wish list and wish features including creation, retrieval, and updates;
fix: modify ban logic to respect expiration timestamps and pardon flags;
refactor: change boolean fields to non-nullable in models and use COALESCE for optional updates in SQL
2025-08-04 20:26:51 +03:00
3bcd8af100 Merge pull request 'Backend: finishing the first milestone' (#6) from feat-profile_service into main
Reviewed-on: #6
2025-08-03 00:25:04 +03:00
b24ffcf3f8 feat: birthday validation integrated into profile 2025-08-03 00:22:01 +03:00
3a63a14c4d refactor: implemented privacy checks in the GetProfileByUsername method;
refactor: reworked sql request for privacy-checking profile getter
2025-08-02 23:37:16 +03:00
5ed75c350a feat: remove authentication requirement for avatar and image upload endpoints;
fix: remove 500 error responses from upload endpoints;
fix: return validation error strings instead of error lists;
fix: handle invalid avatar upload IDs with 400 Bad Request response;
fix: add missing S3Controller to controller initialization;
fix: change avatar_upload_id to string type and update validation rules;
chore: add license header to smtp.go;
refactor: replace manual proxy implementation with httputil.ReverseProxy;
fix: inject S3Service dependency into ProfileService;
fix: set color and color_grad fields during profile update;
fix: correct DTO mapping for profile and settings;
fix: check object existence before copying in SaveUpload;
fix: adjust profile DTO mapping function for proper pointer handling
2025-08-02 03:47:56 +03:00
669349e020 chore: remove direct avatar upload endpoint (POST /profile/avatar);
feat: add endpoints for presigned upload URLs (GET /upload/avatar, GET /upload/image);
refactor: replace ProfileDto with NewProfileDto in update profile endpoint;
feat: implement S3 integration for avatar management;
fix: update database queries to handle new avatar upload flow;
chore: add new dependencies for S3 handling (golang.org/x/time);
refactor: rename UploadService to S3Service;
refactor: change return type for func LocalizeS3Url(originalURL string) (*url.URL, error);
feat: add custom validator for upload_id
2025-08-01 04:34:06 +03:00
8dba0f79aa feat: UploadService controller with ratelimit middleware 2025-07-31 18:10:45 +03:00
08b3942d35 feat: UploadService for acquiring temporary file upload urls for different objects (currently avatars and general images) 2025-07-31 17:46:47 +03:00
0a38267cb0 feat: configuration parameters for minio host, port, timeout;
refactor: renamed buckets;
fix: corrected Host header changing behavior in minio gin endpoint;
feat: function to convert local minio url to /s3/ path url with the backend host
2025-07-30 14:26:27 +03:00
ed044590a0 feat: setup direct access to minio endpoint to images and avatars buckets through /s3/ path 2025-07-29 20:57:36 +03:00
e15ee90a62 feat: Automatic creation of buckets and setting expiration rules 2025-07-28 01:41:01 +03:00
fbe73e2a68 feat: bucket precreation for minioclient 2025-07-27 16:14:12 +03:00
2809e1bd37 feat: enable minio ftp 2025-07-26 22:23:43 +03:00
f08cb639a5 feat: added minio dependency 2025-07-23 21:11:49 +03:00
d14f90d628 feat: complete profile update and settings management
refactor: change profile update endpoints to PUT
refactor: changed profile settings update query to use username
chore: update SQL queries for profile operations
2025-07-23 17:46:59 +03:00
f38af13dc1 feat: GetProfileByUsername implemented 2025-07-21 23:16:09 +03:00
705b420b9e feat: implemented own profile getter;
experiment: using custom automapper function to map profile to profileDto;
refactor: adjusted ProfileService to use pointer return types with models
2025-07-20 21:39:21 +03:00
df54829a67 fix: change avatar upload response to JSON object with URL;
feat: add UrlDto for standardized URL responses;
refactor: update avatar upload endpoint to return UrlDto;
docs: regenerate Swagger;
chore: add comments for untested profile controller methods
2025-07-19 23:23:56 +03:00
f65439fb50 feat: fully implement profile controller;
feat: implement file upload handling in controller with size and type validation;
feat: add custom validation rules for bio and color hex fields;
refactor: enhance request handling with dedicated client info extraction;
chore: update profile DTOs with validation tags;
docs: profile controller swagger
2025-07-19 22:57:44 +03:00
fc0c73aa5b feat: added go-automapper for mapping dtos;
feat: implemented mapspecial package for mapping dtos that are not possible to automap by default;
initialized profile service;
added dtos for profile and profileSettings
2025-07-19 11:44:15 +03:00
6f7d8bf244 refactor: updated profiles 2025-07-18 21:08:18 +03:00
6588190e8b refactor: renamed claims file;
chore: removed more unused stuff
2025-07-18 00:10:52 +03:00
8f04566b5a chore: regenerated swagger;
chore: removed deprecated and unused stuff
2025-07-17 23:15:14 +03:00
f2274f6c58 Merge pull request 'refactor-controllers' (#5) from refactor-controllers into main
Reviewed-on: #5
2025-07-17 22:39:19 +03:00
feb0524d39 Merge pull request 'refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func;' (#4) from fix-swaggo into refactor-controllers
Reviewed-on: #4
2025-07-17 22:38:34 +03:00
f2753e1495 refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func;
fix: swagger docs work now;
chore: remove incomplete account and profile controllers;
fix: correct client info type in request middleware
2025-07-17 22:37:07 +03:00
d6e2d02bff refactor: transitioned auth controller to use the new controller structure;
feat: setup DI for controllers;
refactor: marked old utils and routes package parts as deprecated
2025-07-17 21:42:47 +03:00
f9d7439def fix: Setup interface mismatch;
refactor: GetRequest now panics on missing client_info since it is only supposed to be used on handlers behind AuthMiddleware
2025-07-17 17:53:07 +03:00
7298ab662f experiment: prototyping new ASP.NET-like controllers;
feat: ControllerMethod struct for storing data about an individual API endpoint;
feat: controllerImpl struct for setting up a controller;
feat: GetRequest method for parsing and validating a request with automatic abortion on binding/validation errors
2025-07-17 17:20:48 +03:00
ec56f64420 fix: wrong role 'guest' instead of 'user' defaulting in schema 2025-07-17 04:35:34 +03:00
249bbe4a98 feat: add user role support to database and queries;
fix: add max length validation for refresh token in RefreshRequest;
refactor: use named constants for cache durations in AuthService;
refactor: select all user columns in GetValidUserByLoginCredentials query;
2025-07-17 04:31:25 +03:00
b986d45d82 fix: handle large terminated sessions caching with pagination to prevent RAM overflow;
feat: add paginated query for terminated sessions GUIDs with limit and offset;
refactor: batch processing terminated sessions in Redis with pipeline;
chore: log batch caching progress for terminated sessions;
fix: set TTL for session termination cache keys (8 hours);
refactor: update SQL query for terminated sessions to use pagination;
fix: correct loop structure in auth service initialization
2025-07-17 04:09:15 +03:00
827928178e feat: add change password endpoint using old password;
feat: implement change password service method with validation;
fix: correct ErrorIsOneOf function logic to return true on match;
refactor: rename 'log_out_accounts' to 'log_out_sessions' for clarity;
refactor: update session termination to return GUIDs and cache in Redis;
fix: ensure RollbackOnError only rolls back uncommitted transactions;
fix: handle transaction commit errors properly in dbHelper;
refactor: add helper methods for session termination and registration;
refactor: pass client info to login and registration complete methods;
fix: improve token validation error handling in refresh endpoint;
refactor: update auth middleware to set session info correctly;
chore: remove unused ClientInfo DTO;
fix: correct password reset complete to use session termination helper;
refactor: adjust database queries for session management;
chore: update SQL schema and queries for sessions;
docs: update swagger docs with new endpoint and model changes
2025-07-17 03:44:22 +03:00
48 changed files with 3418 additions and 1081 deletions

View File

@@ -15,17 +15,17 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// @title Easywish client API
// @version 1.0
// @description Easy and feature-rich wishlist.
// @license.name GPL-3.0
// @title Easywish client API
// @version 1.0
// @description Easy and feature-rich wishlist.
// @license.name GPL-3.0
// @BasePath /api/
// @Schemes http
// @BasePath /api/
// @Schemes http
// @securityDefinitions.apikey JWT
// @in header
// @name Authorization
// @in header
// @name Authorization
package main
@@ -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) {

View File

@@ -1,17 +1,17 @@
// 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/>.
@@ -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 {

View File

@@ -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,30 +285,17 @@ const docTemplate = `{
"tags": [
"Profile"
],
"summary": "Get own profile when authorized",
"responses": {}
}
},
"/profile/privacy": {
"get": {
"security": [
{
"JWT": []
"summary": "Get your profile",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get profile privacy settings",
"responses": {}
}
},
"patch": {
"put": {
"security": [
{
"JWT": []
@@ -325,8 +310,89 @@ const docTemplate = `{
"tags": [
"Profile"
],
"summary": "Update profile privacy settings",
"responses": {}
"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": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile settings",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile's settings",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/{username}": {
@@ -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
}
}
},

View File

@@ -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,30 +281,17 @@
"tags": [
"Profile"
],
"summary": "Get own profile when authorized",
"responses": {}
}
},
"/profile/privacy": {
"get": {
"security": [
{
"JWT": []
"summary": "Get your profile",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get profile privacy settings",
"responses": {}
}
},
"patch": {
"put": {
"security": [
{
"JWT": []
@@ -321,8 +306,89 @@
"tags": [
"Profile"
],
"summary": "Update profile privacy settings",
"responses": {}
"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": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile settings",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile's settings",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/{username}": {
@@ -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
}
}
},

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,6 @@ package controllers
import (
errs "easywish/internal/errors"
"easywish/internal/middleware"
"easywish/internal/models"
"easywish/internal/services"
"easywish/internal/utils"
@@ -31,72 +30,91 @@ 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}
}
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true " "
// @Success 200 {object} models.LoginResponse " "
// @Failure 403 "Invalid login credentials"
// @Router /auth/login [post]
func (a *authControllerImpl) Login(c *gin.Context) {
request, ok := utils.GetRequest[models.LoginRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
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,
},
},
}
}
response, err := a.auth.Login(request.Body)
// @Summary Register an account
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification"
// @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post]
func (ctrl *AuthController) registrationBeginHandler(c *gin.Context) {
request, err := GetRequest[models.RegistrationBeginRequest](c)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Request password reset email
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetBeginRequest true " "
// @Router /auth/passwordResetBegin [post]
// @Success 200 "Reset code sent to the email if it is attached to an account"
// @Failure 429 "Too many recent requests for this email"
func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
request, ok := utils.GetRequest[models.PasswordResetBeginRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.PasswordResetBegin(request.Body)
_, err = ctrl.auth.RegistrationBegin(request.Body)
if err != nil {
if errors.Is(err, errs.ErrTooManyRequests) {
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)
@@ -104,125 +122,24 @@ func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Complete password reset via email code
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetCompleteRequest true " "
// @Router /auth/passwordResetComplete [post]
// @Success 200 {object} models.PasswordResetCompleteResponse " "
// @Success 403 "Wrong verification code or username"
func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
request, ok := utils.GetRequest[models.PasswordResetCompleteRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.PasswordResetComplete(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RefreshRequest true " "
// @Router /auth/refresh [post]
// @Success 200 {object} models.RefreshResponse " "
// @Failure 401 "Invalid refresh token"
func (a *authControllerImpl) Refresh(c *gin.Context) {
request, ok := utils.GetRequest[models.RefreshRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.Refresh(request.Body)
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)
if err != nil {
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
c.Status(http.StatusConflict)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
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)
// @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 := a.auth.RegistrationComplete(request.Body)
response, err := ctrl.auth.RegistrationComplete(request.User, request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
@@ -233,15 +150,150 @@ func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
}
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)
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true " "
// @Success 200 {object} models.LoginResponse " "
// @Failure 403 "Invalid login credentials"
// @Router /auth/login [post]
func (ctrl *AuthController) loginHandler(c *gin.Context) {
request, err := GetRequest[models.LoginRequest](c)
if err != nil {
return
}
response, err := ctrl.auth.Login(request.User, request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RefreshRequest true " "
// @Router /auth/refresh [post]
// @Success 200 {object} models.RefreshResponse " "
// @Failure 401 "Invalid refresh token"
func (ctrl *AuthController) refreshHandler(c *gin.Context) {
request, err := GetRequest[models.RefreshRequest](c)
if err != nil {
return
}
response, err := ctrl.auth.Refresh(request.Body)
if err != nil {
if utils.ErrorIsOneOf(
err,
errs.ErrTokenExpired,
errs.ErrTokenInvalid,
errs.ErrInvalidToken,
errs.ErrWrongTokenType,
errs.ErrSessionNotFound,
errs.ErrSessionTerminated,
) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Request password reset email
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetBeginRequest true " "
// @Router /auth/passwordResetBegin [post]
// @Success 200 "Reset code sent to the email if it is attached to an account"
// @Failure 429 "Too many recent requests for this email"
func (ctrl *AuthController) passwordResetBeginHandler(c *gin.Context) {
request, err := GetRequest[models.PasswordResetBeginRequest](c)
if err != nil {
return
}
_, err = ctrl.auth.PasswordResetBegin(request.Body)
if err != nil {
if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusTooManyRequests)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusOK)
}
// @Summary Complete password reset via email code
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetCompleteRequest true " "
// @Router /auth/passwordResetComplete [post]
// @Success 200 {object} models.PasswordResetCompleteResponse " "
// @Success 403 "Wrong verification code or username"
func (ctrl *AuthController) passwordResetCompleteHandler(c *gin.Context) {
request, err := GetRequest[models.PasswordResetCompleteRequest](c)
if err != nil {
return
}
response, err := ctrl.auth.PasswordResetComplete(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Set new password using the old password
// @Tags Auth
// @Accept json
// @Produce json
// @Security JWT
// @Param request body models.ChangePasswordRequest true " "
// @Success 200 "Password successfully changed"
// @Failure 403 "Invalid old password"
// @Router /auth/changePassword [post]
func (ctrl *AuthController) changePasswordHandler(c *gin.Context) {
request, err := GetRequest[models.ChangePasswordRequest](c)
if err != nil {
return
}
_, err = ctrl.auth.ChangePassword(request.Body, request.User)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusOK)
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controllers
import (
"easywish/internal/dto"
"easywish/internal/services"
"easywish/internal/utils/enums"
"easywish/internal/validation"
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
var (
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"
)
type ControllerMethod struct {
HttpMethod string
Path string
Authorization enums.Role
Middleware []gin.HandlerFunc
Function func (c *gin.Context)
}
type controllerImpl struct {
Path string
Middleware []gin.HandlerFunc
Methods []ControllerMethod
}
type Controller interface {
Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService)
}
func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) {
ctrlGroup := group.Group(ctrl.Path)
ctrlGroup.Use(ctrl.Middleware...)
for _, method := range ctrl.Methods {
ctrlGroup.Handle(
method.HttpMethod,
method.Path,
append(
method.Middleware,
gin.HandlerFunc(func(c *gin.Context) {
clientInfo, _ := c.Get("client_info")
if clientInfo.(dto.ClientInfo).Role < method.Authorization {
c.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{"error": "Insufficient authorization for this method"})
return
}
}),
method.Function)...,
)
}
}
func GetFile(c *gin.Context, name string, maxSize int64, allowedTypes map[string]bool) (*string, error) {
file, err := c.FormFile(name); if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("File '%s': not provided", name)})
return nil, err
}
if file.Size > int64(maxSize) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("File '%s': file too large", name)})
return nil, fmt.Errorf("File too large")
}
fileType := file.Header.Get("Content-Type")
if len(allowedTypes) > 0 && !allowedTypes[fileType] {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("File '%s': forbidden file type: %s", name, fileType)})
return nil, fmt.Errorf("Wrong file type")
}
folderPath := "/tmp/uploads"
if _, err := os.Stat(folderPath); os.IsNotExist(err) {
os.MkdirAll(folderPath, 0700)
}
filePath := fmt.Sprintf("%s/%s-%s", folderPath, uuid.New().String(), filepath.Base(file.Filename))
if err := c.SaveUploadedFile(file, filePath); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Error saving file"})
return nil, err
}
return &filePath, nil
}
func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) {
var body ModelT
if err := c.ShouldBindJSON(&body); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, err
}
// TODO: Think hard on a singleton for better performance
validate := validation.NewValidator()
if err := validate.Struct(body); err != nil {
c.AbortWithStatusJSON(
http.StatusBadRequest,
gin.H{"error": err.Error()})
return nil, err
}
cinfo := GetClientInfo(c)
return &dto.Request[ModelT]{
Body: body,
User: cinfo,
}, nil
}
func GetClientInfo(c * gin.Context) (dto.ClientInfo) {
cinfoFromCtx, ok := c.Get("client_info"); if !ok {
c.AbortWithStatusJSON(
http.StatusInternalServerError,
gin.H{"error": "Client info was not found"})
panic("No client_info found in gin context. Does the handler use AuthMiddleware?")
}
cinfo := cinfoFromCtx.(dto.ClientInfo)
return cinfo
}

View File

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

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controllers
import (
"easywish/internal/middleware"
"easywish/internal/models"
"easywish/internal/services"
"easywish/internal/utils/enums"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/time/rate"
)
type S3Controller struct {
log *zap.Logger
s3 services.S3Service
}
func NewS3Controller(_log *zap.Logger, _us services.S3Service) Controller {
ctrl := S3Controller{log: _log, s3: _us}
return &controllerImpl{
Path: "/upload",
Middleware: []gin.HandlerFunc{
middleware.RateLimitMiddleware(rate.Every(2*time.Second), 1),
},
Methods: []ControllerMethod{
{
HttpMethod: GET,
Path: "/avatar",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getAvatarUploadUrl,
},
{
HttpMethod: GET,
Path: "/image",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getImageUploadUrl,
},
},
}
}
// @Summary Get presigned URL for avatar upload
// @Tags Upload
// @Accept json
// @Produce json
// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
// @Router /upload/avatar [get]
func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) {
url, formData, err := ctrl.s3.CreateAvatarUrl()
if err != nil {
ctrl.log.Error("Failed to generate avatar upload URL", zap.Error(err))
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, models.PresignedUploadResponse{
Url: *url,
Fields: *formData,
})
}
// @Summary Get presigned URL for image upload
// @Tags Upload
// @Accept json
// @Produce json
// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
// @Router /upload/image [get]
func (ctrl *S3Controller) getImageUploadUrl(c *gin.Context) {
url, formData, err := ctrl.s3.CreateImageUrl()
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, models.PresignedUploadResponse{
Url: *url,
Fields: *formData,
})
}

View File

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

View File

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

View File

@@ -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
}
return errCommit
d.isCommited = true
}
return nil
return errCommit
}
// 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 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

View File

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

View File

@@ -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 {
Username string
Name string
Birthday pgtype.Timestamp
Bio *string
AvatarUrl *string
Color *string
ColorGrad *string
HideProfileDetails *bool
type GetProfileByUsernameWithPrivacyRow struct {
Username string
Name string
Bio string
AvatarUrl string
Birthday pgtype.Timestamp
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)
`
@@ -776,10 +847,14 @@ type GetValidUserByLoginCredentialsParams struct {
}
type GetValidUserByLoginCredentialsRow struct {
ID int64
Username string
PasswordHash string
TotpEncrypted *string
ID int64
Username string
Verified bool
RegistrationDate pgtype.Timestamp
Role int32
Deleted *bool
PasswordHash string
TotpEncrypted *string
}
func (q *Queries) GetValidUserByLoginCredentials(ctx context.Context, arg GetValidUserByLoginCredentialsParams) (GetValidUserByLoginCredentialsRow, error) {
@@ -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
}

View File

@@ -1,34 +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 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"`
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dto
type ProfileDto struct {
Name string `json:"name"`
Bio string `json:"bio"`
AvatarUrl *string `json:"avatar_url"`
Birthday int64 `json:"birthday"`
Color string `json:"color"`
ColorGrad string `json:"color_grad"`
}
type NewProfileDto struct {
Name string `json:"name" binding:"required" validate:"name"`
Bio string `json:"bio" validate:"bio"`
AvatarUploadID string `json:"avatar_upload_id" validate:"omitempty,upload_id=avatar"`
Birthday int64 `json:"birthday" validate:"birthday_unix_milli"`
Color string `json:"color" validate:"color_hex"`
ColorGrad string `json:"color_grad" validate:"color_hex"`
}
type ProfileSettingsDto struct {
HideFulfilled bool `json:"hide_fulfilled"`
HideProfileDetails bool `json:"hide_profile_details"`
HideForUnauthenticated bool `json:"hide_for_unauthenticated"`
HideBirthday bool `json:"hide_birthday"`
HideDates bool `json:"hide_dates"`
Captcha bool `json:"captcha"`
FollowersOnlyInteraction bool `json:"followers_only_interaction"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
}

View File

@@ -1,35 +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
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 func(c *gin.Context) {
if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
return conn, ctx, nil
}

View File

@@ -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()
})
}

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ type RegistrationCompleteRequest struct {
Username string `json:"username" binding:"required" validate:"username"`
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
}

View File

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

View File

@@ -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
}
return &request, true
type PresignedUploadResponse struct {
Url string `json:"url"`
Fields map[string]string `json:"fields"`
}

View File

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

View File

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

View File

@@ -35,48 +35,136 @@ 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)
}
type authServiceImpl struct {
log *zap.Logger
log *zap.Logger
dbctx database.DbContext
redis *redis.Client
smtp SmtpService
smtp SmtpService
}
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
db := database.NewDbHelper(_dbctx)
guids, err := db.Queries.GetUnexpiredTerminatedSessionsGuids(db.CTX)
if err != nil {
panic("Failed to load terminated sessions' GUIDs")
}
ctx := context.TODO()
// FIXME: review possible problems due to a large pipeline request
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())
db := database.NewDbHelper(_dbctx)
// Batch processing parameters
batchSize := 1000
offset := 0
totalCached := 0
for {
guids, err := db.Queries.GetUnexpiredTerminatedSessionsGuidsPaginated(
db.CTX,
database.GetUnexpiredTerminatedSessionsGuidsPaginatedParams{
BatchSize: int32(batchSize),
Offset: int64(offset),
},
)
if err != nil {
panic("Failed to load terminated sessions' GUIDs: " + err.Error())
}
// Break loop when no more records
if len(guids) == 0 {
break
}
// Process batch in Redis pipeline
pipe := _redis.Pipeline()
for _, guid := range guids {
key := fmt.Sprintf("session::%s::is_terminated", guid)
pipe.Set(ctx, key, true, AuthTerminatedSessionCacheDuration)
}
if _, err := pipe.Exec(ctx); err != nil {
panic("Failed to cache terminated sessions: " + err.Error())
}
totalCached += len(guids)
offset += len(guids)
_log.Info(
"Cached batch of terminated sessions",
zap.Int("batch_size", len(guids)),
zap.Int("total_cached", totalCached))
}
_log.Info("Finished caching terminated sessions",
zap.Int("total_sessions", totalCached),
)
return authService
}
_log.Info("Cached terminated sessions' GUIDs in Redis", zap.Int("amount", len(guids)))
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) {
@@ -91,18 +179,17 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
"Failed to open a transaction",
zap.Error(err))
return false, errs.ErrServerError
}
defer helper.RollbackOnError(err)
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 {
request.Email),
).Bool(); if err != nil {
if err != redis.Nil {
a.log.Error(
"Failed to look up cached registration_in_progress state of email as part of registration procedure",
@@ -113,13 +200,13 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
isInProgress = false
} else if isInProgress {
a.log.Warn(
"Attempted to begin registration on email that is in progress of registration or on cooldown",
"Attempted to begin registration on email that is in progress of registration or on cooldown",
zap.String("email", request.Email))
return false, errs.ErrTooManyRequests
}
if occupationStatus, err = db.TXQueries.CheckUserRegistrationAvailability(db.CTX, database.CheckUserRegistrationAvailabilityParams{
Email: request.Email,
Email: request.Email,
Username: request.Username,
}); err != nil {
a.log.Error(
@@ -141,12 +228,12 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
// Falsely confirm in order to avoid disclosing registered email addresses
if err := a.redis.Set(
context.TODO(),
fmt.Sprintf("email::%s::registration_in_progress", request.Email),
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",
"Failed to falsely set cache registration_in_progress state for email as a measure to prevent email enumeration",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
@@ -161,7 +248,7 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
} else {
if _, err := db.TXQueries.DeleteUnverifiedAccountsHavingUsernameOrEmail(db.CTX, database.DeleteUnverifiedAccountsHavingUsernameOrEmailParams{
Username: request.Username,
Email: request.Email,
Email: request.Email,
}); err != nil {
a.log.Error(
"Failed to purge unverified accounts as part of registration",
@@ -184,8 +271,8 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
}
if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{
UserID: user.ID,
Email: utils.NewPointer(request.Email),
UserID: user.ID,
Email: utils.NewPointer(request.Email),
PasswordHash: passwordHash, // Hashed in database
}); err != nil {
@@ -208,9 +295,9 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
}
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
UserID: user.ID,
CodeType: int32(enums.RegistrationCodeType),
CodeHash: generatedCodeHash, // Hashed in database
UserID: user.ID,
CodeType: int32(enums.RegistrationCodeType),
CodeHash: generatedCodeHash, // Hashed in database
}); err != nil {
a.log.Error("Failed to add registration code to database", zap.Error(err))
return false, errs.ErrServerError
@@ -224,8 +311,8 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
if config.GetConfig().SmtpEnabled {
if err := a.smtp.SendEmail(
request.Email,
"Easywish",
request.Email,
"Easywish",
fmt.Sprintf("Your registration code is %s", generatedCode),
); err != nil {
a.log.Error(
@@ -246,12 +333,12 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
if err := a.redis.Set(
context.TODO(),
fmt.Sprintf("email::%s::registration_in_progress", request.Email),
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",
"Failed to cache registration_in_progress state for email",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
@@ -261,25 +348,32 @@ 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 confirmationCode database.ConfirmationCode
var accessToken, refreshToken string
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
@@ -290,8 +384,8 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
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),
"Could not find user attempting to complete registration with given username",
zap.String("username", request.Username),
zap.Error(err))
return nil, errs.ErrUserNotFound
}
@@ -304,9 +398,9 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
}
confirmationCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{
UserID: user.ID,
UserID: user.ID,
CodeType: int32(enums.RegistrationCodeType),
Code: request.VerificationCode,
Code: request.VerificationCode,
})
if err != nil {
@@ -327,10 +421,10 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
}
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: confirmationCode.ID,
Used: utils.NewPointer(true),
ID: confirmationCode.ID,
Used: true,
})
if err != nil {
a.log.Error(
"Failed to update the user's registration code used state",
@@ -340,27 +434,32 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return nil, errs.ErrServerError
}
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
ID: user.ID,
Verified: utils.NewPointer(true),
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
ID: user.ID,
Verified: true,
})
if err != nil {
a.log.Error("Failed to update verified status for user",
zap.String("username", user.Username),
zap.Error(err))
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{
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,
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
}
@@ -369,29 +468,17 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
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
}
// TODO: get user role
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String(), enums.UserRole)
session, err := a.registerSession(context.TODO(), user.ID, cinfo, &db.TXQueries); if err != nil {
a.log.Error("", zap.Error(err))
return nil, errs.ErrServerError
}
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String(), enums.Role(user.Role))
if err != nil {
a.log.Error(
@@ -413,37 +500,30 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
zap.String("username", request.Username))
response := models.RegistrationCompleteResponse{Tokens: models.Tokens{
AccessToken: accessToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
}}
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)
if err != nil {
a.log.Error(
"Failed to open a transaction",
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.RollbackOnError(err)
defer helper.RollbackOnError(err)
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),
@@ -500,7 +573,7 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
}
response := models.LoginResponse{Tokens: models.Tokens{
AccessToken: accessToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
}}
@@ -509,12 +582,13 @@ 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,
err,
errs.ErrInvalidToken,
errs.ErrTokenExpired,
errs.ErrTokenExpired,
errs.ErrWrongTokenType,
errs.ErrSessionNotFound,
errs.ErrSessionTerminated,
@@ -522,19 +596,20 @@ func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.Refres
return nil, err
} else {
a.log.Error(
"Encountered an unexpected error while validating token",
"Encountered an unexpected error while validating token",
zap.Error(err))
return nil, errs.ErrServerError
}
}
}
accessToken, refreshToken, err := utils.GenerateTokens(
sessionInfo.Username,
sessionInfo.Session,
sessionInfo.Role,
); if err != nil {
)
if err != nil {
a.log.Error(
"Failed to generate tokens for user during refresh",
"Failed to generate tokens for user during refresh",
zap.String("username", sessionInfo.Username),
zap.String("session", sessionInfo.Session),
zap.Error(err))
@@ -543,7 +618,7 @@ func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.Refres
response := models.RefreshResponse{
Tokens: models.Tokens{
AccessToken: accessToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
},
}
@@ -551,13 +626,13 @@ func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.Refres
return &response, nil
}
func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error) {
func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error) {
var err error
token, err := jwt.ParseWithClaims(
jwtToken,
&dto.UserClaims{},
jwtToken,
&dto.UserClaims{},
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
@@ -569,11 +644,12 @@ func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtToke
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errs.ErrTokenExpired
}
}
return nil, errs.ErrInvalidToken
}
claims, ok := token.Claims.(*dto.UserClaims); if ok && token.Valid {
claims, ok := token.Claims.(*dto.UserClaims)
if ok && token.Valid {
if claims.Type != tokenType {
return nil, errs.ErrWrongTokenType
@@ -583,7 +659,7 @@ func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtToke
isTerminated, redisErr := a.redis.Get(ctx, fmt.Sprintf("session::%s::is_terminated", claims.Session)).Bool()
if redisErr != nil && redisErr != redis.Nil {
a.log.Error(
"Failed to lookup cache to check whether session is not terminated",
"Failed to lookup cache to check whether session is not terminated",
zap.Error(redisErr))
return nil, redisErr
}
@@ -603,20 +679,20 @@ func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtToke
}
a.log.Error(
"Failed to lookup session in database",
"Failed to lookup session in database",
zap.String("session", claims.Session),
zap.Error(err))
return nil, err
}
if err := a.redis.Set(
ctx,
fmt.Sprintf("session::%s::is_terminated", claims.Session),
*session.Terminated,
time.Duration(8 * time.Hour), // XXX: magic number
ctx,
fmt.Sprintf("session::%s::is_terminated", claims.Session),
*session.Terminated,
AuthTerminatedSessionCacheDuration,
).Err(); err != nil {
a.log.Error(
"Failed to cache session's is_terminated state",
"Failed to cache session's is_terminated state",
zap.String("session", claims.Session),
zap.Error(err))
// c.AbortWithStatus(http.StatusInternalServerError)
@@ -633,15 +709,15 @@ func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtToke
sessionInfo := dto.SessionInfo{
Username: claims.Username,
Session: claims.Session,
Role: claims.Role,
Session: claims.Session,
Role: claims.Role,
}
return &sessionInfo, nil
}
func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error) {
var user database.User
var generatedCode, hashedCode string
var err error
@@ -649,7 +725,7 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
"Failed to open a transaction",
zap.Error(err))
return false, errs.ErrServerError
}
@@ -660,7 +736,7 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
cooldownTimeUnix, redisErr := a.redis.Get(ctx, fmt.Sprintf("email::%s::reset_cooldown", request.Email)).Int64()
if redisErr != nil && redisErr != redis.Nil {
a.log.Error(
"Failed to get reset_cooldown state for user",
"Failed to get reset_cooldown state for user",
zap.String("email", request.Email),
zap.Error(redisErr))
return false, errs.ErrServerError
@@ -677,7 +753,7 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
if errors.Is(err, pgx.ErrNoRows) {
// Enable cooldown for the email despite that account does not exist
err := a.redis.Set(
ctx,
ctx,
fmt.Sprintf("email::%s::reset_cooldown", request.Email),
time.Now().Add(10*time.Minute),
time.Duration(10*time.Minute),
@@ -687,7 +763,7 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
a.log.Error(
"Failed to set reset cooldown for email",
zap.Error(err))
return false, err
return false, err
}
a.log.Warn(
@@ -712,7 +788,7 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
}
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
UserID: user.ID,
UserID: user.ID,
CodeType: int32(enums.PasswordResetCodeType),
CodeHash: hashedCode,
}); err != nil {
@@ -723,7 +799,7 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
}
err = a.redis.Set(
ctx,
ctx,
fmt.Sprintf("email::%s::reset_cooldown", request.Email),
time.Now().Add(10*time.Minute),
time.Duration(10*time.Minute),
@@ -733,13 +809,21 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
a.log.Error(
"Failed to set reset cooldown for email. Cancelling password reset",
zap.Error(err))
return false, err
return false, err
}
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::reset_cooldown", request.Email))
if redisErr != nil {
a.log.Error(
"Failed to delete cooldown redis key while rolling back PasswordResetBegin",
zap.Error(redisErr.Err()))
}
return false, errs.ErrServerError
}
@@ -757,7 +841,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
@@ -779,13 +863,13 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
}
if resetCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{
UserID: user.ID,
UserID: user.ID,
CodeType: int32(enums.PasswordResetCodeType),
Code: request.VerificationCode,
Code: request.VerificationCode,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.log.Warn(
"Attempted to reset password for user using incorrect confirmation code",
"Attempted to reset password for user using incorrect confirmation code",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.String("provided_code", request.VerificationCode),
@@ -795,8 +879,8 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
}
if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: resetCode.ID,
Used: utils.NewPointer(true),
ID: resetCode.ID,
Used: true,
}); err != nil {
a.log.Error(
"Failed to invalidate password reset code upon use",
@@ -817,7 +901,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
}
if err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
Username: user.Username,
Username: user.Username,
PasswordHash: hashedPassword,
}); err != nil {
a.log.Error(
@@ -828,33 +912,35 @@ 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{
UserID: user.ID,
Name: utils.NewPointer("First device"),
// 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",
"Failed to create new session for user as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
}
// 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",
"Failed to generate tokens as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
@@ -863,7 +949,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
response := models.PasswordResetCompleteResponse{
Tokens: models.Tokens{
AccessToken: accessToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
},
}
@@ -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
}

View File

@@ -0,0 +1,224 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package services
import (
"easywish/internal/database"
"easywish/internal/dto"
errs "easywish/internal/errors"
"easywish/internal/utils"
mapspecial "easywish/internal/utils/mapSpecial"
"errors"
"time"
"github.com/go-redis/redis/v8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/minio/minio-go/v7"
"github.com/rafiulgits/go-automapper"
"go.uber.org/zap"
)
type ProfileService interface {
GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error)
GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error)
UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error)
GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error)
UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error)
}
type profileServiceImpl struct {
log *zap.Logger
dbctx database.DbContext
redis *redis.Client
minio *minio.Client
s3 S3Service
}
func NewProfileService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _minio *minio.Client, _s3 S3Service) ProfileService {
return &profileServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, minio: _minio, s3: _s3}
}
func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) {
db := database.NewDbHelper(p.dbctx);
profile, err := db.Queries.GetProfileByUsername(db.CTX, cinfo.Username); if err != nil {
p.log.Error(
"Failed to find user profile by username",
zap.Error(err))
return nil, errs.ErrServerError
}
profileDto := &dto.ProfileDto{}
mapspecial.MapProfileDto(profile, profileDto)
return profileDto, nil
}
func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil {
p.log.Error(
"Failed to start transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.Rollback()
profileRow, err := db.TXQueries.GetProfileByUsernameWithPrivacy(db.CTX, database.GetProfileByUsernameWithPrivacyParams{
Requester: cinfo.Username,
SearchedUsername: username,
}); if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.ErrNotFound
}
p.log.Error(
"Failed to get user profile by username",
zap.String("username", username),
zap.Error(err))
return nil, errs.ErrServerError
}
if !*profileRow.AccessAllowed {
return nil, errs.ErrForbidden
}
profileDto := &dto.ProfileDto{
Name: profileRow.Name,
Bio: profileRow.Bio,
AvatarUrl: &profileRow.AvatarUrl,
Birthday: profileRow.Birthday.Time.UnixMilli(),
Color: profileRow.Color,
ColorGrad: profileRow.ColorGrad,
}
return profileDto, nil
}
func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) {
db := database.NewDbHelper(p.dbctx);
profileSettings, err := db.Queries.GetProfileSettingsByUsername(db.CTX, cinfo.Username); if err != nil {
p.log.Error(
"Failed to find user profile settings by username",
zap.Error(err))
return nil, errs.ErrServerError
}
profileSettingsDto := &dto.ProfileSettingsDto{}
automapper.Map(profileSettings, profileSettingsDto)
return profileSettingsDto, nil
}
func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil {
p.log.Error(
"Failed to open transaction",
zap.Error(err))
return false, errs.ErrServerError
}
defer helper.Rollback()
birthdayTimestamp := pgtype.Timestamp {
Time: time.UnixMilli(newProfile.Birthday),
Valid: true,
}
var avatarUrl *string
if newProfile.AvatarUploadID != "" {
key, err := p.s3.SaveUpload(newProfile.AvatarUploadID, "avatars"); if err != nil {
if errors.Is(err, errs.ErrFileNotFound) {
return false, err
}
p.log.Error("Failed to save avatar",
zap.String("upload_id", newProfile.AvatarUploadID),
zap.Error(err))
return false, errs.ErrServerError
}
urlObj := p.s3.GetLocalizedFileUrl(*key, "avatars")
avatarUrl = utils.NewPointer(urlObj.String())
}
err = db.TXlessQueries.UpdateProfileByUsername(db.CTX, database.UpdateProfileByUsernameParams{
Username: cinfo.Username,
Name: newProfile.Name,
Bio: newProfile.Bio,
Birthday: birthdayTimestamp,
AvatarUrl: avatarUrl,
Color: newProfile.Color,
ColorGrad: newProfile.ColorGrad,
}); if err != nil {
p.log.Error(
"Failed to update user profile",
zap.String("username", cinfo.Username),
zap.Error(err))
return false, errs.ErrServerError
}
err = helper.Commit(); if err != nil {
p.log.Error(
"Failed to commit transaction",
zap.Error(err))
return false, errs.ErrServerError
}
return true, nil
}
func (p *profileServiceImpl) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil {
p.log.Error(
"Failed to open transaction",
zap.Error(err))
return false, err
}
defer helper.Rollback()
// I wanted an automapper here but I'm feeling lazy, it's not used anywhere else regardless.
// Also this was initially meant to be a PATCH request but I realized that the fields in the
// DTO model are not pointers. Too late, guess this is a PUT request now. Who the hell cares
// about a couple extra bytes you have to send every now and then anyways.
err = db.TXlessQueries.UpdateProfileSettingsByUsername(db.CTX, database.UpdateProfileSettingsByUsernameParams{
Username: cinfo.Username,
HideFulfilled: newProfileSettings.HideFulfilled,
HideProfileDetails: newProfileSettings.HideProfileDetails,
HideForUnauthenticated: newProfileSettings.HideForUnauthenticated,
HideBirthday: newProfileSettings.HideBirthday,
Captcha: newProfileSettings.Captcha,
FollowersOnlyInteraction: newProfileSettings.FollowersOnlyInteraction,
}); if err != nil {
p.log.Error(
"Failed to update user profile settings",
zap.String("username", cinfo.Username),
zap.Error(err))
return false, errs.ErrServerError
}
err = helper.Commit(); if err != nil {
p.log.Error(
"Failed to commit transaction",
zap.Error(err))
return false, errs.ErrServerError
}
return true, nil
}

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package services
import (
"context"
"easywish/config"
minioclient "easywish/internal/minioClient"
errs "easywish/internal/errors"
"easywish/internal/utils"
"fmt"
"net/url"
"time"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"go.uber.org/zap"
)
type S3Service interface {
CreateAvatarUrl() (*string, *map[string]string, error)
CreateImageUrl() (*string, *map[string]string, error)
SaveUpload(uploadID string, bucket string) (*string, error)
GetLocalizedFileUrl(key string, bucket string) url.URL
}
type s3ServiceImpl struct {
minio *minio.Client
log *zap.Logger
avatarPolicy minio.PostPolicy
imagePolicy minio.PostPolicy
}
func NewUploadService(_minio *minio.Client, _log *zap.Logger) S3Service {
service := s3ServiceImpl{
minio: _minio,
log: _log,
}
avatarPolicy := minio.NewPostPolicy()
imagePolicy := minio.NewPostPolicy()
// At the moment the parameters match for both policies but this may
// change with introduction of new policies
for _, policy := range [...]*minio.PostPolicy{avatarPolicy, imagePolicy} {
if err := policy.SetBucket(minioclient.Buckets["uploads"]); err != nil {
panic("Failed to set bucket for policy: " + err.Error())
}
if err := policy.SetExpires(time.Now().UTC().Add(10 * time.Minute)); err != nil {
panic("Failed to set expiration time for policy: " + err.Error())
}
if err := policy.SetContentTypeStartsWith("image/"); err != nil {
panic("Failed to set allowed content types for the policy: " + err.Error())
}
if err := policy.SetContentLengthRange(1, 512*1024); err != nil {
panic("Failed to set allowed content length range for the policy: " + err.Error())
}
}
service.imagePolicy = *imagePolicy
service.avatarPolicy = *avatarPolicy
return &service
}
func (s *s3ServiceImpl) genUrl(policy minio.PostPolicy, prefix string) (*string, *map[string]string, error) {
object := prefix + uuid.New().String()
if err := policy.SetKey(object); err != nil {
s.log.Error(
"Failed to set random key for presigned url",
zap.Error(err))
return nil, nil, err
}
url, formData, err := s.minio.PresignedPostPolicy(context.Background(), &policy)
if err != nil {
s.log.Error(
"Failed to generate presigned url",
zap.String("object", object),
zap.Error(err))
return nil, nil, err
}
convertedUrl, err := utils.LocalizeS3Url(url.String())
if err != nil {
s.log.Error(
"Failed to localize object URL to user-accessible format",
zap.String("url", url.String()),
zap.Error(err))
return nil, nil, err
}
return utils.NewPointer(convertedUrl.String()), &formData, nil
}
func (s *s3ServiceImpl) CreateAvatarUrl() (*string, *map[string]string, error) {
return s.genUrl(s.avatarPolicy, "avatar-")
}
func (s *s3ServiceImpl) CreateImageUrl() (*string, *map[string]string, error) {
return s.genUrl(s.imagePolicy, "image-")
}
func (s *s3ServiceImpl) SaveUpload(uploadID string, bucketAlias string) (*string, error) {
sourceBucket := minioclient.Buckets["uploads"]
bucket := minioclient.Buckets[bucketAlias]
newObjectKey := uuid.New().String()
_, err := s.minio.StatObject(context.Background(), sourceBucket, uploadID, minio.StatObjectOptions{})
if err != nil {
if minio.ToErrorResponse(err).Code == minio.NoSuchKey {
return nil, errs.ErrFileNotFound
}
}
_, err = s.minio.CopyObject(context.Background(), minio.CopyDestOptions{
Bucket: bucket,
Object: newObjectKey,
}, minio.CopySrcOptions{
Bucket: sourceBucket,
Object: uploadID,
})
if err != nil {
s.log.Error(
"Failed to copy object to new bucket",
zap.String("sourceBucket", sourceBucket),
zap.String("uploadID", uploadID),
zap.String("destinationBucket", bucket),
zap.String("newObjectKey", newObjectKey),
zap.Error(err))
return nil, err
}
err = s.minio.RemoveObject(context.Background(), sourceBucket, uploadID, minio.RemoveObjectOptions{})
if err != nil {
s.log.Error(
"Failed to remove original object from uploads bucket",
zap.String("sourceBucket", sourceBucket),
zap.String("uploadID", uploadID),
zap.Error(err))
return nil, err
}
return &newObjectKey, nil
}
func (s *s3ServiceImpl) GetLocalizedFileUrl(key string, bucketAlias string) url.URL {
cfg := config.GetConfig()
return url.URL{
Scheme: "http",
Host: fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port),
Path: fmt.Sprintf("/s3/%s/%s", minioclient.Buckets[bucketAlias], key),
}
}

View File

@@ -23,7 +23,9 @@ import (
var Module = fx.Module("services",
fx.Provide(
NewUploadService,
NewSmtpService,
NewAuthService,
NewProfileService,
),
)

View File

@@ -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 true
return false
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mapspecial
import (
"easywish/internal/database"
"easywish/internal/dto"
"github.com/rafiulgits/go-automapper"
)
func MapProfileDto(dbModel database.Profile, dtoModel *dto.ProfileDto) {
if dtoModel == nil {
dtoModel = &dto.ProfileDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.Profile, dst *dto.ProfileDto) {
dst.Birthday = src.Birthday.Time.UnixMilli()
})
}

View File

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

View File

@@ -21,6 +21,7 @@ import (
"easywish/config"
"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 {
@@ -96,6 +127,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)
}},
}

View File

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

View File

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

View File

@@ -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;
--: }}}

View File

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