FEATURE (init): Make internal project public

This commit is contained in:
Rostislav Dugin
2025-06-05 16:11:50 +03:00
commit a49a11774a
221 changed files with 21692 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# docker-compose.yml
DB_NAME=postgresus
DB_USERNAME=postgres
DB_PASSWORD=Q1234567

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
postgresus_data/
.env
pgdata/

View File

@@ -0,0 +1,14 @@
---
description:
globs:
alwaysApply: true
---
Always place private methods to the bottom of file
Code should look like:
type SomeService struct {
func PublicMethod(...) ...
func privateMethod(...) ...
}

View File

@@ -0,0 +1,7 @@
---
description:
globs:
alwaysApply: true
---
Do not write obsious comments.
Write meaningful code, give meaningful names

View File

@@ -0,0 +1,55 @@
---
description:
globs:
alwaysApply: true
---
1. When we write controller:
- we combine all routes to single controller
- names them as .WhatWeDo (not "handlers") concept
2. We use gin and *gin.Context for all routes.
Example:
func (c *TasksController) GetAvailableTasks(ctx *gin.Context) ...
3. We document all routes with Swagger in the following format:
// SignIn
// @Summary Authenticate a user
// @Description Authenticate a user with email and password
// @Tags users
// @Accept json
// @Produce json
// @Param request body SignInRequest true "User signin data"
// @Success 200 {object} SignInResponse
// @Failure 400
// @Router /users/signin [post]
Do not forget to write docs.
You can avoid description if it is useless.
Specify particular acceping \ producing models
4. All controllers should have RegisterRoutes method which receives
RouterGroup (always put this routes on the top of file under controller definition)
Example:
func (c *OrderController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/bots/users/orders/generate", c.GenerateOrder)
router.POST("/bots/users/orders/generate-by-admin", c.GenerateOrderByAdmin)
router.GET("/bots/users/orders/mark-as-paid-by-admin", c.MarkOrderAsPaidByAdmin)
router.GET("/bots/users/orders/payments-by-bot", c.GetOrderPaymentsByBot)
router.GET("/bots/users/orders/payments-by-user", c.GetOrderPaymentsByUser)
router.GET("/bots/users/orders/orders-by-user-for-admin", c.GetOrdersByUserForAdmin)
router.POST("/bots/users/orders/orders-by-user-for-user", c.GetOrdersByUserForUser)
router.POST("/bots/users/orders/order", c.GetOrder)
router.POST("/bots/users/orders/cancel-subscription-by-user", c.CancelSubscriptionByUser)
router.GET("/bots/users/orders/cancel-subscription-by-admin", c.CancelSubscriptionByAdmin)
router.GET(
"/bots/users/orders/cancel-subscriptions-by-payment-option",
c.CancelSubscriptionsByPaymentOption,
)
}
5. Check that use use valid .Query("param") and .Param("param") methods.
If route does not have param - use .Query("query")

View File

@@ -0,0 +1,74 @@
---
description:
globs:
alwaysApply: true
---
For DI files use implicit fields declaration styles (espesially
for controllers, services, repositories, use cases, etc., not simple
data structures).
So, instead of:
var orderController = &OrderController{
orderService: orderService,
botUserService: bot_users.GetBotUserService(),
botService: bots.GetBotService(),
userService: users.GetUserService(),
}
Use:
var orderController = &OrderController{
orderService,
bot_users.GetBotUserService(),
bots.GetBotService(),
users.GetUserService(),
}
This is needed to avoid forgetting to update DI style
when we add new dependency.
---
Please force such usage if file look like this (see some
services\controllers\repos definitions and getters):
var orderBackgroundService = &OrderBackgroundService{
orderService: orderService,
orderPaymentRepository: orderPaymentRepository,
botService: bots.GetBotService(),
paymentSettingsService: payment_settings.GetPaymentSettingsService(),
orderSubscriptionListeners: []OrderSubscriptionListener{},
}
var orderController = &OrderController{
orderService: orderService,
botUserService: bot_users.GetBotUserService(),
botService: bots.GetBotService(),
userService: users.GetUserService(),
}
func GetUniquePaymentRepository() *repositories.UniquePaymentRepository {
return uniquePaymentRepository
}
func GetOrderPaymentRepository() *repositories.OrderPaymentRepository {
return orderPaymentRepository
}
func GetOrderService() *OrderService {
return orderService
}
func GetOrderController() *OrderController {
return orderController
}
func GetOrderBackgroundService() *OrderBackgroundService {
return orderBackgroundService
}
func GetOrderRepository() *repositories.OrderRepository {
return orderRepository
}

View File

@@ -0,0 +1,27 @@
---
description:
globs:
alwaysApply: true
---
When writting migrations:
- write them for PostgreSQL
- for PRIMARY UUID keys use gen_random_uuid()
- for time use TIMESTAMPTZ (timestamp with zone)
- split table, constraint and indexes declaration (table first, them other one by one)
- format SQL in pretty way (add spaces, align columns types), constraints split by lines. The example:
CREATE TABLE marketplace_info (
bot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT NOT NULL,
short_description TEXT NOT NULL,
tutorial_url TEXT,
info_order BIGINT NOT NULL DEFAULT 0,
is_published BOOLEAN NOT NULL DEFAULT FALSE
);
ALTER TABLE marketplace_info_images
ADD CONSTRAINT fk_marketplace_info_images_bot_id
FOREIGN KEY (bot_id)
REFERENCES marketplace_info (bot_id);

View File

@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Always use time.Now().UTC() instead of time.Now()

13
backend/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# docker-compose.yml
DB_NAME=postgresus
DB_USERNAME=postgres
DB_PASSWORD=Q1234567
#app
ENV_MODE=development
# db
DATABASE_DSN=host=localhost user=postgres password=Q1234567 dbname=postgresus port=5437 sslmode=disable
DATABASE_URL=postgres://postgres:Q1234567@localhost:5437/postgresus?sslmode=disable
# migrations
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:Q1234567@localhost:5437/postgresus?sslmode=disable
GOOSE_MIGRATION_DIR=./migrations

12
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
main
.env
docker-compose.yml
pgdata
pgdata_test/
main.exe
swagger/
swagger/*
swagger/docs.go
swagger/swagger.json
swagger/swagger.yaml
postgresus-backend.exe

19
backend/.golangci.yml Normal file
View File

@@ -0,0 +1,19 @@
version: "2"
run:
timeout: 1m
tests: false
concurrency: 4
linters:
default: standard
settings:
errcheck:
check-type-assertions: true
formatters:
enable:
- gofmt
- golines
- goimports

View File

@@ -0,0 +1,15 @@
repos:
- repo: local
hooks:
- id: golangci-lint-fmt
name: Format Go Code using golangci-lint fmt
entry: golangci-lint fmt ./...
language: system
types: [go]
- id: golangci-lint-run
name: Run golangci-lint for static analysis
entry: golangci-lint run
language: system
types: [go]
pass_filenames: false

44
backend/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM golang:1.23.3
EXPOSE 4005
WORKDIR /app
# Install PostgreSQL APT repository and all versions 13-17
RUN apt-get update && apt-get install -y wget ca-certificates gnupg lsb-release
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN apt-get update
# Install PostgreSQL client tools for versions 13-17
RUN apt-get install -y \
postgresql-client-13 \
postgresql-client-14 \
postgresql-client-15 \
postgresql-client-16 \
postgresql-client-17
# Create symlinks to match expected paths (/usr/pgsql-{VERSION}/bin)
RUN for version in 13 14 15 16 17; do \
mkdir -p /usr/pgsql-${version}/bin; \
ln -sf /usr/bin/pg_dump /usr/pgsql-${version}/bin/pg_dump; \
ln -sf /usr/bin/psql /usr/pgsql-${version}/bin/psql; \
ln -sf /usr/bin/pg_restore /usr/pgsql-${version}/bin/pg_restore; \
ln -sf /usr/bin/createdb /usr/pgsql-${version}/bin/createdb; \
ln -sf /usr/bin/dropdb /usr/pgsql-${version}/bin/dropdb; \
done
# Install global dependencies
RUN curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh
RUN goose -version
RUN go install github.com/swaggo/swag/cmd/swag@latest
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN swag init -d . -g cmd/main.go -o swagger
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main ./cmd/main.go
CMD ["./main"]

68
backend/README.md Normal file
View File

@@ -0,0 +1,68 @@
# Before run
Keep in mind: you need to use dev-db from docker-compose.yml in this folder
instead of postgresus-db from docker-compose.yml in the root folder.
> Copy .env.example to .env
> Copy docker-compose.yml.example to docker-compose.yml (for development only)
# Run
To build:
> go build /cmd/main.go
To run:
> go run /cmd/main.go
Before commit (make sure `golangci-lint` is installed):
> golangci-lint fmt
> golangci-lint run
# Migrations
To create migration:
> goose create MIGRATION_NAME sql
To run migrations:
> goose up
If latest migration failed:
To rollback on migration:
> goose down
# Swagger
To generate swagger docs:
> swag init -g .\cmd\main.go -o swagger
Swagger URL is:
> http://localhost:8080/api/v1/docs/swagger/index.html#/
# Project structure
Default endpoint structure is:
/feature
/feature/controller.go
/feature/service.go
/feature/repository.go
/feature/model.go
/feature/dto.go
If there are couple of models:
/feature/models/model1.go
/feature/models/model2.go
...
# Project rules
Always use time.Now().UTC() instead of time.Now()

188
backend/cmd/main.go Normal file
View File

@@ -0,0 +1,188 @@
package main
import (
"context"
"log/slog"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"net/http"
"postgresus-backend/internal/config"
"postgresus-backend/internal/downdetect"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/restores"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
"postgresus-backend/internal/storage"
env_utils "postgresus-backend/internal/util/env"
files_utils "postgresus-backend/internal/util/files"
"postgresus-backend/internal/util/logger"
_ "postgresus-backend/swagger" // swagger docs
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// @title Postgresus Backend API
// @version 1.0
// @description API for Postgresus
// @termsOfService http://swagger.io/terms/
// @host localhost:4005
// @BasePath /api/v1
// @schemes http
func main() {
log := logger.GetLogger()
runMigrations(log)
go generateSwaggerDocs(log)
_ = storage.GetDb()
gin.SetMode(gin.ReleaseMode)
ginApp := gin.Default()
setUpRoutes(ginApp)
setUpDependencies()
runBackgroundTasks(log)
startServerWithGracefulShutdown(log, ginApp)
}
func startServerWithGracefulShutdown(log *slog.Logger, app *gin.Engine) {
host := ""
if config.GetEnv().EnvMode == env_utils.EnvModeDevelopment {
// for dev we use localhost to avoid firewall
// requests on each run for Windows
host = "127.0.0.1"
}
srv := &http.Server{
Addr: host + ":4005",
Handler: app,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("listen:", "error", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Info("Shutdown signal received")
// The context is used to inform the server it has 10 seconds to finish
// the request it is currently handling
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error("Server forced to shutdown:", "error", err)
}
log.Info("Server gracefully stopped")
}
func setUpRoutes(r *gin.Engine) {
v1 := r.Group("/api/v1")
// Mount Swagger UI
v1.GET("/docs/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
downdetectContoller := downdetect.GetDowndetectController()
userController := users.GetUserController()
notifierController := notifiers.GetNotifierController()
storageController := storages.GetStorageController()
databaseController := databases.GetDatabaseController()
backupController := backups.GetBackupController()
restoreController := restores.GetRestoreController()
downdetectContoller.RegisterRoutes(v1)
userController.RegisterRoutes(v1)
notifierController.RegisterRoutes(v1)
storageController.RegisterRoutes(v1)
databaseController.RegisterRoutes(v1)
backupController.RegisterRoutes(v1)
restoreController.RegisterRoutes(v1)
}
func setUpDependencies() {
backups.SetupDependencies()
}
func runBackgroundTasks(log *slog.Logger) {
log.Info("Preparing to run background tasks...")
err := files_utils.CleanFolder(config.GetEnv().TempFolder)
if err != nil {
log.Error("Failed to clean temp folder", "error", err)
}
go runWithPanicLogging(log, "backup background service", func() {
backups.GetBackupBackgroundService().Run()
})
go runWithPanicLogging(log, "restore background service", func() {
restores.GetRestoreBackgroundService().Run()
})
}
func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) {
defer func() {
if r := recover(); r != nil {
log.Error("Panic in "+serviceName, "error", r)
}
}()
fn()
}
// Keep in mind: docs appear after second launch, because Swagger
// is generated into Go files. So if we changed files, we generate
// new docs, but still need to restart the server to see them.
func generateSwaggerDocs(log *slog.Logger) {
// Run swag from the current directory instead of parent
// Use the current directory as the base for swag init
// This ensures swag can find the files regardless of where the command is run from
currentDir, err := os.Getwd()
if err != nil {
log.Error("Failed to get current directory", "error", err)
return
}
cmd := exec.Command("swag", "init", "-d", currentDir, "-g", "cmd/main.go", "-o", "swagger")
output, err := cmd.CombinedOutput()
if err != nil {
log.Error("Failed to generate Swagger docs", "error", err, "output", string(output))
return
}
log.Info("Swagger documentation generated successfully")
}
func runMigrations(log *slog.Logger) {
log.Info("Running database migrations...")
cmd := exec.Command("goose", "up")
cmd.Env = append(os.Environ(), "GOOSE_DRIVER=postgres", "GOOSE_DBSTRING="+config.GetEnv().DatabaseDsn)
// Set the working directory to where migrations are located
cmd.Dir = "./migrations"
output, err := cmd.CombinedOutput()
if err != nil {
log.Error("Failed to run migrations", "error", err, "output", string(output))
os.Exit(1)
}
log.Info("Database migrations completed successfully", "output", string(output))
}

View File

@@ -0,0 +1,20 @@
version: "3"
services:
# For development only, because this DB
# exposes it's port to the public
dev-db:
env_file:
- .env
image: postgres:17
ports:
- "5437:5437"
environment:
- POSTGRES_DB=${DEV_DB_NAME}
- POSTGRES_USER=${DEV_DB_USERNAME}
- POSTGRES_PASSWORD=${DEV_DB_PASSWORD}
volumes:
- ./pgdata:/var/lib/postgresql/data
container_name: dev-db
command: -p 5437
shm_size: 10gb

81
backend/go.mod Normal file
View File

@@ -0,0 +1,81 @@
module postgresus-backend
go 1.23.3
require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/pressly/goose/v3 v3.24.3
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.10.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.92 // 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.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.0 // indirect
github.com/swaggo/swag v1.16.4 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.5.11 // indirect
gorm.io/gorm v1.26.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)

214
backend/go.sum Normal file
View File

@@ -0,0 +1,214 @@
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
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.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/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.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/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/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/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.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
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.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/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/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
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.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.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-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/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=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
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=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,120 @@
package config
import (
"os"
"path/filepath"
env_utils "postgresus-backend/internal/util/env"
"postgresus-backend/internal/util/logger"
"postgresus-backend/internal/util/tools"
"strings"
"sync"
"github.com/ilyakaznacheev/cleanenv"
"github.com/joho/godotenv"
)
var log = logger.GetLogger()
const (
AppModeWeb = "web"
AppModeBackground = "background"
)
type EnvVariables struct {
IsTesting bool
DatabaseDsn string `env:"DATABASE_DSN" required:"true"`
EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"`
PostgresesInstallDir string `env:"POSTGRES_INSTALL_DIR"`
DataFolder string
TempFolder string
}
var (
env EnvVariables
once sync.Once
)
func GetEnv() EnvVariables {
once.Do(loadEnvVariables)
return env
}
func loadEnvVariables() {
// Get current working directory
cwd, err := os.Getwd()
if err != nil {
log.Warn("could not get current working directory", "error", err)
cwd = "."
}
projectRoot := cwd
for {
if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil {
break
}
parent := filepath.Dir(projectRoot)
if parent == projectRoot {
break
}
projectRoot = parent
}
envPaths := []string{
filepath.Join(cwd, ".env"),
filepath.Join(projectRoot, ".env"),
}
var loaded bool
for _, path := range envPaths {
log.Info("Trying to load .env", "path", path)
if err := godotenv.Load(path); err == nil {
log.Info("Successfully loaded .env", "path", path)
loaded = true
break
}
}
if !loaded {
log.Error("Error loading .env file: could not find .env in any location")
os.Exit(1)
}
err = cleanenv.ReadEnv(&env)
if err != nil {
log.Error("Configuration could not be loaded", "error", err)
os.Exit(1)
}
for _, arg := range os.Args {
if strings.Contains(arg, "test") {
env.IsTesting = true
break
}
}
if env.DatabaseDsn == "" {
log.Error("DATABASE_DSN is empty")
os.Exit(1)
}
if env.EnvMode == "" {
log.Error("ENV_MODE is empty")
os.Exit(1)
}
if env.EnvMode != "development" && env.EnvMode != "production" {
log.Error("ENV_MODE is invalid", "mode", env.EnvMode)
os.Exit(1)
}
log.Info("ENV_MODE loaded", "mode", env.EnvMode)
env.PostgresesInstallDir = "./tools/postgresql"
tools.VerifyPostgresesInstallation(env.EnvMode, env.PostgresesInstallDir)
env.DataFolder = "../postgresus-data/data"
env.TempFolder = "../postgresus-data/temp"
log.Info("Environment variables loaded successfully!")
}

View File

@@ -0,0 +1,23 @@
package config
import (
"os"
"os/signal"
"syscall"
)
var isShutDownSignalReceived = false
func StartListeningForShutdownSignal() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
go func() {
<-quit
isShutDownSignalReceived = true
}()
}
func IsShouldShutdown() bool {
return isShutDownSignalReceived
}

View File

@@ -0,0 +1,37 @@
package downdetect
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
type DowndetectController struct {
service *DowndetectService
}
func (c *DowndetectController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/downdetect/is-available", c.IsAvailable)
}
// @Summary Check API availability
// @Description Checks if the API service is available
// @Tags downdetect
// @Accept json
// @Produce json
// @Success 200
// @Failure 500
// @Router /downdetect/api [get]
func (c *DowndetectController) IsAvailable(ctx *gin.Context) {
err := c.service.IsDbAvailable()
if err != nil {
ctx.JSON(
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("Database is not available: %v", err)},
)
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "API and DB are available"})
}

View File

@@ -0,0 +1,10 @@
package downdetect
var downdetectService = &DowndetectService{}
var downdetectController = &DowndetectController{
downdetectService,
}
func GetDowndetectController() *DowndetectController {
return downdetectController
}

View File

@@ -0,0 +1,17 @@
package downdetect
import (
"postgresus-backend/internal/storage"
)
type DowndetectService struct {
}
func (s *DowndetectService) IsDbAvailable() error {
err := storage.GetDb().Exec("SELECT 1").Error
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,161 @@
package backups
import (
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/util/logger"
"time"
)
type BackupBackgroundService struct {
backupService *BackupService
backupRepository *BackupRepository
databaseService *databases.DatabaseService
}
var log = logger.GetLogger()
func (s *BackupBackgroundService) Run() {
if err := s.failBackupsInProgress(); err != nil {
log.Error("Failed to fail backups in progress", "error", err)
panic(err)
}
if config.IsShouldShutdown() {
return
}
for {
if config.IsShouldShutdown() {
return
}
if err := s.cleanOldBackups(); err != nil {
log.Error("Failed to clean old backups", "error", err)
}
if err := s.runPendingBackups(); err != nil {
log.Error("Failed to run pending backups", "error", err)
}
time.Sleep(1 * time.Minute)
}
}
func (s *BackupBackgroundService) failBackupsInProgress() error {
backupsInProgress, err := s.backupRepository.FindByStatus(BackupStatusInProgress)
if err != nil {
return err
}
for _, backup := range backupsInProgress {
failMessage := "Backup failed due to application restart"
backup.FailMessage = &failMessage
backup.Status = BackupStatusFailed
backup.BackupSizeMb = 0
s.backupService.SendBackupNotification(
backup.Database,
backup,
databases.NotificationBackupFailed,
&failMessage,
)
if err := s.backupRepository.Save(backup); err != nil {
return err
}
}
return nil
}
func (s *BackupBackgroundService) cleanOldBackups() error {
allDatabases, err := s.databaseService.GetAllDatabases()
if err != nil {
return err
}
for _, database := range allDatabases {
backupStorePeriod := database.StorePeriod
if backupStorePeriod == databases.PeriodForever {
continue
}
storeDuration := backupStorePeriod.ToDuration()
dateBeforeBackupsShouldBeDeleted := time.Now().UTC().Add(-storeDuration)
oldBackups, err := s.backupRepository.FindBackupsBeforeDate(
database.ID,
dateBeforeBackupsShouldBeDeleted,
)
if err != nil {
log.Error(
"Failed to find old backups for database",
"databaseId",
database.ID,
"error",
err,
)
continue
}
for _, backup := range oldBackups {
backup.DeleteBackupFromStorage()
if err := s.backupRepository.DeleteByID(backup.ID); err != nil {
log.Error("Failed to delete old backup", "backupId", backup.ID, "error", err)
continue
}
log.Info("Deleted old backup", "backupId", backup.ID, "databaseId", database.ID)
}
}
return nil
}
func (s *BackupBackgroundService) runPendingBackups() error {
allDatabases, err := s.databaseService.GetAllDatabases()
if err != nil {
return err
}
for _, database := range allDatabases {
if database.BackupInterval == nil {
continue
}
lastBackup, err := s.backupRepository.FindLastByDatabaseID(database.ID)
if err != nil {
log.Error(
"Failed to get last backup for database",
"databaseId",
database.ID,
"error",
err,
)
continue
}
var lastBackupTime *time.Time
if lastBackup != nil {
lastBackupTime = &lastBackup.CreatedAt
}
if database.BackupInterval.ShouldTriggerBackup(time.Now().UTC(), lastBackupTime) {
log.Info(
"Triggering scheduled backup",
"databaseId",
database.ID,
"intervalType",
database.BackupInterval.Interval,
)
go s.backupService.MakeBackup(database.ID)
log.Info("Successfully triggered scheduled backup", "databaseId", database.ID)
}
}
return nil
}

View File

@@ -0,0 +1,145 @@
package backups
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type BackupController struct {
backupService *BackupService
userService *users.UserService
}
func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/backups", c.GetBackups)
router.POST("/backups", c.MakeBackup)
router.DELETE("/backups/:id", c.DeleteBackup)
}
// GetBackups
// @Summary Get backups for a database
// @Description Get all backups for the specified database
// @Tags backups
// @Produce json
// @Param database_id query string true "Database ID"
// @Success 200 {array} Backup
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /backups [get]
func (c *BackupController) GetBackups(ctx *gin.Context) {
databaseIDStr := ctx.Query("database_id")
if databaseIDStr == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "database_id query parameter is required"})
return
}
databaseID, err := uuid.Parse(databaseIDStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database_id"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
backups, err := c.backupService.GetBackups(user, databaseID)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, backups)
}
// MakeBackup
// @Summary Create a backup
// @Description Create a new backup for the specified database
// @Tags backups
// @Accept json
// @Produce json
// @Param request body MakeBackupRequest true "Backup creation data"
// @Success 200 {object} map[string]string
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /backups [post]
func (c *BackupController) MakeBackup(ctx *gin.Context) {
var request MakeBackupRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.backupService.MakeBackupWithAuth(user, request.DatabaseID); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "backup started successfully"})
}
// DeleteBackup
// @Summary Delete a backup
// @Description Delete an existing backup
// @Tags backups
// @Param id path string true "Backup ID"
// @Success 204
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /backups/{id} [delete]
func (c *BackupController) DeleteBackup(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.backupService.DeleteBackup(user, id); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.Status(http.StatusNoContent)
}
type MakeBackupRequest struct {
DatabaseID uuid.UUID `json:"database_id" binding:"required"`
}

View File

@@ -0,0 +1,52 @@
package backups
import (
"postgresus-backend/internal/features/backups/usecases"
usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
)
var createPostgresqlBackupUsecase = &usecases_postgresql.CreatePostgresqlBackupUsecase{}
var createBackupUseCase = &usecases.CreateBackupUsecase{
CreatePostgresqlBackupUsecase: createPostgresqlBackupUsecase,
}
var backupRepository = &BackupRepository{}
var backupService = &BackupService{
databases.GetDatabaseService(),
storages.GetStorageService(),
backupRepository,
notifiers.GetNotifierService(),
createBackupUseCase,
}
var backupBackgroundService = &BackupBackgroundService{
backupService,
backupRepository,
databases.GetDatabaseService(),
}
var backupController = &BackupController{
backupService,
users.GetUserService(),
}
func SetupDependencies() {
databases.
GetDatabaseService().
SetDatabaseStorageChangeListener(backupService)
}
func GetBackupService() *BackupService {
return backupService
}
func GetBackupController() *BackupController {
return backupController
}
func GetBackupBackgroundService() *BackupBackgroundService {
return backupBackgroundService
}

View File

@@ -0,0 +1,10 @@
package backups
type BackupStatus string
const (
BackupStatusInProgress BackupStatus = "IN_PROGRESS"
BackupStatusCompleted BackupStatus = "COMPLETED"
BackupStatusFailed BackupStatus = "FAILED"
BackupStatusDeleted BackupStatus = "DELETED"
)

View File

@@ -0,0 +1,41 @@
package backups
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/storages"
"time"
"github.com/google/uuid"
)
type Backup struct {
ID uuid.UUID `json:"id" gorm:"column:id;type:uuid;primaryKey"`
Database *databases.Database `json:"database" gorm:"foreignKey:DatabaseID"`
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;not null"`
Storage *storages.Storage `json:"storage" gorm:"foreignKey:StorageID"`
StorageID uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;not null"`
Status BackupStatus `json:"status" gorm:"column:status;not null"`
FailMessage *string `json:"failMessage" gorm:"column:fail_message"`
BackupSizeMb float64 `json:"backupSizeMb" gorm:"column:backup_size_mb;default:0"`
BackupDurationMs int64 `json:"backupDurationMs" gorm:"column:backup_duration_ms;default:0"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
}
func (b *Backup) DeleteBackupFromStorage() {
if b.Status != BackupStatusCompleted {
return
}
err := b.Storage.DeleteFile(b.ID)
if err != nil {
log.Error("Failed to delete backup from storage", "error", err)
// we ignore the error, because the access to the storage
// may be lost, file already deleted, etc.
}
}

View File

@@ -0,0 +1,137 @@
package backups
import (
"postgresus-backend/internal/storage"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type BackupRepository struct{}
func (r *BackupRepository) Save(backup *Backup) error {
db := storage.GetDb()
isNew := backup.ID == uuid.Nil
if isNew {
backup.ID = uuid.New()
return db.Create(backup).
Omit("Database", "Storage").
Error
}
return db.Save(backup).
Omit("Database", "Storage").
Error
}
func (r *BackupRepository) FindByDatabaseID(databaseID uuid.UUID) ([]*Backup, error) {
var backups []*Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("database_id = ?", databaseID).
Order("created_at DESC").
Find(&backups).Error; err != nil {
return nil, err
}
return backups, nil
}
func (r *BackupRepository) FindLastByDatabaseID(databaseID uuid.UUID) (*Backup, error) {
var backup Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("database_id = ?", databaseID).
Order("created_at DESC").
First(&backup).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &backup, nil
}
func (r *BackupRepository) FindByID(id uuid.UUID) (*Backup, error) {
var backup Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("id = ?", id).
First(&backup).Error; err != nil {
return nil, err
}
return &backup, nil
}
func (r *BackupRepository) FindByStatus(status BackupStatus) ([]*Backup, error) {
var backups []*Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("status = ?", status).
Order("created_at DESC").
Find(&backups).Error; err != nil {
return nil, err
}
return backups, nil
}
func (r *BackupRepository) FindByStorageIdAndStatus(
storageID uuid.UUID,
status BackupStatus,
) ([]*Backup, error) {
var backups []*Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("storage_id = ? AND status = ?", storageID, status).
Order("created_at DESC").
Find(&backups).Error; err != nil {
return nil, err
}
return backups, nil
}
func (r *BackupRepository) DeleteByID(id uuid.UUID) error {
return storage.GetDb().Delete(&Backup{}, "id = ?", id).Error
}
func (r *BackupRepository) FindBackupsBeforeDate(
databaseID uuid.UUID,
date time.Time,
) ([]*Backup, error) {
var backups []*Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("database_id = ? AND created_at < ?", databaseID, date).
Order("created_at DESC").
Find(&backups).Error; err != nil {
return nil, err
}
return backups, nil
}

View File

@@ -0,0 +1,319 @@
package backups
import (
"errors"
"fmt"
"postgresus-backend/internal/features/backups/usecases"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
users_models "postgresus-backend/internal/features/users/models"
"slices"
"time"
"github.com/google/uuid"
)
type BackupService struct {
databaseService *databases.DatabaseService
storageService *storages.StorageService
backupRepository *BackupRepository
notifierService *notifiers.NotifierService
createBackupUseCase *usecases.CreateBackupUsecase
}
func (s *BackupService) OnBeforeDbStorageChange(
databaseID uuid.UUID,
storageID uuid.UUID,
) error {
// validate no backups in progress
backups, err := s.backupRepository.FindByStorageIdAndStatus(
storageID,
BackupStatusInProgress,
)
if err != nil {
return err
}
if len(backups) > 0 {
return errors.New("backup is in progress, storage cannot")
}
backupsWithStorage, err := s.backupRepository.FindByStorageIdAndStatus(
storageID,
BackupStatusCompleted,
)
if err != nil {
return err
}
if len(backupsWithStorage) > 0 {
for _, backup := range backupsWithStorage {
if err := backup.Storage.DeleteFile(backup.ID); err != nil {
// most likely we cannot do nothing with this,
// so we just remove the backup model
log.Error("Failed to delete backup file", "error", err)
}
if err := s.backupRepository.DeleteByID(backup.ID); err != nil {
return err
}
}
// we repeat remove for the case if backup
// started until we removed all previous backups
return s.OnBeforeDbStorageChange(databaseID, storageID)
}
return nil
}
func (s *BackupService) MakeBackupWithAuth(
user *users_models.User,
databaseID uuid.UUID,
) error {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
return err
}
if database.UserID != user.ID {
return errors.New("user does not have access to this database")
}
go s.MakeBackup(databaseID)
return nil
}
func (s *BackupService) GetBackups(
user *users_models.User,
databaseID uuid.UUID,
) ([]*Backup, error) {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
return nil, err
}
if database.UserID != user.ID {
return nil, errors.New("user does not have access to this database")
}
backups, err := s.backupRepository.FindByDatabaseID(databaseID)
if err != nil {
return nil, err
}
return backups, nil
}
func (s *BackupService) DeleteBackup(
user *users_models.User,
backupID uuid.UUID,
) error {
backup, err := s.backupRepository.FindByID(backupID)
if err != nil {
return err
}
if backup.Database.UserID != user.ID {
return errors.New("user does not have access to this backup")
}
if backup.Status == BackupStatusInProgress {
return errors.New("backup is in progress")
}
backup.DeleteBackupFromStorage()
backup.Status = BackupStatusDeleted
return s.backupRepository.Save(backup)
}
func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
log.Error("Failed to get database by ID", "error", err)
return
}
lastBackup, err := s.backupRepository.FindLastByDatabaseID(databaseID)
if err != nil {
log.Error("Failed to find last backup by database ID", "error", err)
return
}
if lastBackup != nil && lastBackup.Status == BackupStatusInProgress {
log.Error("Backup is in progress")
return
}
storage, err := s.storageService.GetStorageByID(database.StorageID)
if err != nil {
log.Error("Failed to get storage by ID", "error", err)
return
}
backup := &Backup{
DatabaseID: databaseID,
Database: database,
StorageID: storage.ID,
Storage: storage,
Status: BackupStatusInProgress,
BackupSizeMb: 0,
CreatedAt: time.Now().UTC(),
}
if err := s.backupRepository.Save(backup); err != nil {
log.Error("Failed to save backup", "error", err)
return
}
start := time.Now().UTC()
backupProgressListener := func(
completedMBs float64,
) {
backup.BackupSizeMb = completedMBs
backup.BackupDurationMs = time.Since(start).Milliseconds()
if err := s.backupRepository.Save(backup); err != nil {
log.Error("Failed to update backup progress", "error", err)
}
}
err = s.createBackupUseCase.Execute(
backup.ID,
database,
storage,
backupProgressListener,
)
if err != nil {
errMsg := err.Error()
backup.FailMessage = &errMsg
backup.Status = BackupStatusFailed
backup.BackupDurationMs = time.Since(start).Milliseconds()
backup.BackupSizeMb = 0
if updateErr := s.databaseService.SetBackupError(databaseID, errMsg); updateErr != nil {
log.Error(
"Failed to update database last backup time",
"databaseId",
databaseID,
"error",
updateErr,
)
}
if err := s.backupRepository.Save(backup); err != nil {
log.Error("Failed to save backup", "error", err)
}
s.SendBackupNotification(
database,
backup,
databases.NotificationBackupFailed,
&errMsg,
)
return
}
backup.Status = BackupStatusCompleted
backup.BackupDurationMs = time.Since(start).Milliseconds()
if err := s.backupRepository.Save(backup); err != nil {
log.Error("Failed to save backup", "error", err)
return
}
// Update database last backup time
now := time.Now().UTC()
if updateErr := s.databaseService.SetLastBackupTime(databaseID, now); updateErr != nil {
log.Error(
"Failed to update database last backup time",
"databaseId",
databaseID,
"error",
updateErr,
)
}
s.SendBackupNotification(
database,
backup,
databases.NotificationBackupSuccess,
nil,
)
}
func (s *BackupService) SendBackupNotification(
db *databases.Database,
backup *Backup,
notificationType databases.BackupNotificationType,
errorMessage *string,
) {
database, err := s.databaseService.GetDatabaseByID(db.ID)
if err != nil {
return
}
for _, notifier := range database.Notifiers {
if !slices.Contains(
database.SendNotificationsOn,
notificationType,
) {
continue
}
title := ""
switch notificationType {
case databases.NotificationBackupFailed:
title = fmt.Sprintf("❌ Backup failed for database \"%s\"", database.Name)
case databases.NotificationBackupSuccess:
title = fmt.Sprintf("✅ Backup completed for database \"%s\"", database.Name)
}
message := ""
if errorMessage != nil {
message = *errorMessage
} else {
// Format size conditionally
var sizeStr string
if backup.BackupSizeMb < 1024 {
sizeStr = fmt.Sprintf("%.2f MB", backup.BackupSizeMb)
} else {
sizeGB := backup.BackupSizeMb / 1024
sizeStr = fmt.Sprintf("%.2f GB", sizeGB)
}
// Format duration as "0m 0s 0ms"
totalMs := backup.BackupDurationMs
minutes := totalMs / (1000 * 60)
seconds := (totalMs % (1000 * 60)) / 1000
milliseconds := totalMs % 1000
durationStr := fmt.Sprintf("%dm %ds %dms", minutes, seconds, milliseconds)
message = fmt.Sprintf(
"Backup completed successfully in %s.\nCompressed backup size: %s",
durationStr,
sizeStr,
)
}
s.notifierService.SendNotification(
&notifier,
title,
message,
)
}
}
func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) {
return s.backupRepository.FindByID(backupID)
}

View File

@@ -0,0 +1,35 @@
package usecases
import (
"errors"
usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/storages"
"github.com/google/uuid"
)
type CreateBackupUsecase struct {
CreatePostgresqlBackupUsecase *usecases_postgresql.CreatePostgresqlBackupUsecase
}
// Execute creates a backup of the database and returns the backup size in MB
func (uc *CreateBackupUsecase) Execute(
backupID uuid.UUID,
database *databases.Database,
storage *storages.Storage,
backupProgressListener func(
completedMBs float64,
),
) error {
if database.Type == databases.DatabaseTypePostgres {
return uc.CreatePostgresqlBackupUsecase.Execute(
backupID,
database,
storage,
backupProgressListener,
)
}
return errors.New("database type not supported")
}

View File

@@ -0,0 +1,499 @@
package usecases_postgresql
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/util/logger"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
)
var log = logger.GetLogger()
type CreatePostgresqlBackupUsecase struct{}
// Execute creates a backup of the database
func (uc *CreatePostgresqlBackupUsecase) Execute(
backupID uuid.UUID,
db *databases.Database,
storage *storages.Storage,
backupProgressListener func(
completedMBs float64,
),
) error {
log.Info(
"Creating PostgreSQL backup via pg_dump custom format",
"databaseId",
db.ID,
"storageId",
storage.ID,
)
pg := db.Postgresql
if pg.Database == nil || *pg.Database == "" {
return fmt.Errorf("database name is required for pg_dump backups")
}
args := []string{
"-Fc", // custom format with built-in compression
"-Z", "6", // balanced compression level (0-9, 6 is balanced)
"--no-password", // Use environment variable for password, prevent prompts
"-h", pg.Host,
"-p", strconv.Itoa(pg.Port),
"-U", pg.Username,
"-d", *pg.Database,
"--verbose", // Add verbose output to help with debugging
}
return uc.streamToStorage(
backupID,
tools.GetPostgresqlExecutable(
pg.Version,
"pg_dump",
config.GetEnv().EnvMode,
config.GetEnv().PostgresesInstallDir,
),
args,
pg.Password,
storage,
db,
backupProgressListener,
)
}
// streamToStorage streams pg_dump output directly to storage
func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
backupID uuid.UUID,
pgBin string,
args []string,
password string,
storage *storages.Storage,
db *databases.Database,
backupProgressListener func(completedMBs float64),
) error {
log.Info("Streaming PostgreSQL backup to storage", "pgBin", pgBin, "args", args)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
// Monitor for shutdown and cancel context if needed
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if config.IsShouldShutdown() {
cancel()
return
}
}
}
}()
// Create temporary .pgpass file as a more reliable alternative to PGPASSWORD
pgpassFile, err := uc.createTempPgpassFile(db.Postgresql, password)
if err != nil {
return fmt.Errorf("failed to create temporary .pgpass file: %w", err)
}
defer func() {
if pgpassFile != "" {
_ = os.Remove(pgpassFile)
}
}()
// Verify .pgpass file was created successfully
if pgpassFile == "" {
return fmt.Errorf("temporary .pgpass file was not created")
}
// Verify .pgpass file was created correctly
if info, err := os.Stat(pgpassFile); err == nil {
log.Info("Temporary .pgpass file created successfully",
"pgpassFile", pgpassFile,
"size", info.Size(),
"mode", info.Mode(),
)
} else {
return fmt.Errorf("failed to verify .pgpass file: %w", err)
}
cmd := exec.CommandContext(ctx, pgBin, args...)
log.Info("Executing PostgreSQL backup command", "command", cmd.String())
// Start with system environment variables to preserve Windows PATH, SystemRoot, etc.
cmd.Env = os.Environ()
// Use the .pgpass file for authentication
cmd.Env = append(cmd.Env, "PGPASSFILE="+pgpassFile)
log.Info("Using temporary .pgpass file for authentication", "pgpassFile", pgpassFile)
// Debug password setup (without exposing the actual password)
log.Info("Setting up PostgreSQL environment",
"passwordLength", len(password),
"passwordEmpty", password == "",
"pgBin", pgBin,
"usingPgpassFile", true,
"parallelJobs", db.Postgresql.CpuCount,
)
// Add PostgreSQL-specific environment variables
cmd.Env = append(cmd.Env, "PGCLIENTENCODING=UTF8")
cmd.Env = append(cmd.Env, "PGCONNECT_TIMEOUT=30")
// Add encoding-related environment variables to handle character encoding issues
cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8")
cmd.Env = append(cmd.Env, "LANG=C.UTF-8")
// Add PostgreSQL-specific encoding settings
cmd.Env = append(cmd.Env, "PGOPTIONS=--client-encoding=UTF8")
shouldRequireSSL := db.Postgresql.IsHttps
// Require SSL when explicitly configured
if shouldRequireSSL {
cmd.Env = append(cmd.Env, "PGSSLMODE=require")
log.Info("Using required SSL mode", "configuredHttps", db.Postgresql.IsHttps)
} else {
// SSL not explicitly required, but prefer it if available
cmd.Env = append(cmd.Env, "PGSSLMODE=prefer")
log.Info("Using preferred SSL mode", "configuredHttps", db.Postgresql.IsHttps)
}
// Set other SSL parameters to avoid certificate issues
cmd.Env = append(cmd.Env, "PGSSLCERT=") // No client certificate
cmd.Env = append(cmd.Env, "PGSSLKEY=") // No client key
cmd.Env = append(cmd.Env, "PGSSLROOTCERT=") // No root certificate verification
cmd.Env = append(cmd.Env, "PGSSLCRL=") // No certificate revocation list
// Verify executable exists and is accessible
if _, err := exec.LookPath(pgBin); err != nil {
return fmt.Errorf(
"PostgreSQL executable not found or not accessible: %s - %w",
pgBin,
err,
)
}
pgStdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
pgStderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
// Capture stderr in a separate goroutine to ensure we don't miss any error output
stderrCh := make(chan []byte, 1)
go func() {
stderrOutput, _ := io.ReadAll(pgStderr)
stderrCh <- stderrOutput
}()
// A pipe connecting pg_dump output → storage
storageReader, storageWriter := io.Pipe()
// Create a counting writer to track bytes
countingWriter := &CountingWriter{writer: storageWriter}
// The backup ID becomes the object key / filename in storage
// Start streaming into storage in its own goroutine
saveErrCh := make(chan error, 1)
go func() {
saveErrCh <- storage.SaveFile(backupID, storageReader)
}()
// Start pg_dump
if err = cmd.Start(); err != nil {
return fmt.Errorf("start %s: %w", filepath.Base(pgBin), err)
}
// Copy pg output directly to storage with shutdown checks
copyResultCh := make(chan error, 1)
bytesWrittenCh := make(chan int64, 1)
go func() {
bytesWritten, err := uc.copyWithShutdownCheck(
ctx,
countingWriter,
pgStdout,
backupProgressListener,
)
bytesWrittenCh <- bytesWritten
copyResultCh <- err
}()
// Wait for the dump and copy to finish
waitErr := cmd.Wait()
copyErr := <-copyResultCh
bytesWritten := <-bytesWrittenCh
// Check for shutdown before finalizing
if config.IsShouldShutdown() {
if pipeWriter, ok := countingWriter.writer.(*io.PipeWriter); ok {
if err := pipeWriter.Close(); err != nil {
log.Error("Failed to close counting writer", "error", err)
}
}
<-saveErrCh // Wait for storage to finish
return fmt.Errorf("backup cancelled due to shutdown")
}
// Close the pipe writer to signal end of data
if pipeWriter, ok := countingWriter.writer.(*io.PipeWriter); ok {
if err := pipeWriter.Close(); err != nil {
log.Error("Failed to close counting writer", "error", err)
}
}
// Wait until storage ends reading
saveErr := <-saveErrCh
stderrOutput := <-stderrCh
// Send final sizing after backup is completed
if waitErr == nil && copyErr == nil && saveErr == nil && backupProgressListener != nil {
sizeMB := float64(bytesWritten) / (1024 * 1024)
backupProgressListener(sizeMB)
}
switch {
case waitErr != nil:
if config.IsShouldShutdown() {
return fmt.Errorf("backup cancelled due to shutdown")
}
// Enhanced error handling for PostgreSQL connection and SSL issues
stderrStr := string(stderrOutput)
errorMsg := fmt.Sprintf(
"%s failed: %v stderr: %s",
filepath.Base(pgBin),
waitErr,
stderrStr,
)
// Check for specific PostgreSQL error patterns
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode := exitErr.ExitCode()
// Enhanced debugging for exit status 1 with empty stderr
if exitCode == 1 && strings.TrimSpace(stderrStr) == "" {
log.Error("pg_dump failed with exit status 1 but no stderr output",
"pgBin", pgBin,
"args", args,
"env_vars", []string{
"PGCLIENTENCODING=UTF8",
"PGCONNECT_TIMEOUT=30",
"LC_ALL=C.UTF-8",
"LANG=C.UTF-8",
"PGOPTIONS=--client-encoding=UTF8",
},
)
errorMsg = fmt.Sprintf(
"%s failed with exit status 1 but provided no error details. "+
"This often indicates: "+
"1) Connection timeout or refused connection, "+
"2) Authentication failure with incorrect credentials, "+
"3) Database does not exist, "+
"4) Network connectivity issues, "+
"5) PostgreSQL server not running. "+
"Command executed: %s %s",
filepath.Base(pgBin),
pgBin,
strings.Join(args, " "),
)
} else if exitCode == -1073741819 { // 0xC0000005 in decimal
log.Error("PostgreSQL tool crashed with access violation",
"pgBin", pgBin,
"args", args,
"exitCode", fmt.Sprintf("0x%X", uint32(exitCode)),
)
errorMsg = fmt.Sprintf(
"%s crashed with access violation (0xC0000005). This may indicate incompatible PostgreSQL version, corrupted installation, or connection issues. stderr: %s",
filepath.Base(pgBin),
stderrStr,
)
} else if exitCode == 1 || exitCode == 2 {
// Check for common connection and authentication issues
if containsIgnoreCase(stderrStr, "pg_hba.conf") {
errorMsg = fmt.Sprintf(
"PostgreSQL connection rejected by server configuration (pg_hba.conf). The server may not allow connections from your IP address or may require different authentication settings. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "no password supplied") || containsIgnoreCase(stderrStr, "fe_sendauth") {
errorMsg = fmt.Sprintf(
"PostgreSQL authentication failed - no password supplied. "+
"PGPASSWORD environment variable may not be working correctly on this system. "+
"Password length: %d, Password empty: %v. "+
"Consider using a .pgpass file as an alternative. stderr: %s",
len(password),
password == "",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "ssl") && containsIgnoreCase(stderrStr, "connection") {
errorMsg = fmt.Sprintf(
"PostgreSQL SSL connection failed. The server may require SSL encryption or have SSL configuration issues. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "connection") && containsIgnoreCase(stderrStr, "refused") {
errorMsg = fmt.Sprintf(
"PostgreSQL connection refused. Check if the server is running and accessible from your network. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "authentication") || containsIgnoreCase(stderrStr, "password") {
errorMsg = fmt.Sprintf(
"PostgreSQL authentication failed. Check username and password. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "timeout") {
errorMsg = fmt.Sprintf(
"PostgreSQL connection timeout. The server may be unreachable or overloaded. stderr: %s",
stderrStr,
)
}
}
}
return errors.New(errorMsg)
case copyErr != nil:
if config.IsShouldShutdown() {
return fmt.Errorf("backup cancelled due to shutdown")
}
return fmt.Errorf("copy to storage: %w", copyErr)
case saveErr != nil:
if config.IsShouldShutdown() {
return fmt.Errorf("backup cancelled due to shutdown")
}
return fmt.Errorf("save to storage: %w", saveErr)
}
return nil
}
// copyWithShutdownCheck copies data from src to dst while checking for shutdown
func (uc *CreatePostgresqlBackupUsecase) copyWithShutdownCheck(
ctx context.Context,
dst io.Writer,
src io.Reader,
backupProgressListener func(completedMBs float64),
) (int64, error) {
buf := make([]byte, 32*1024) // 32KB buffer
var totalBytesWritten int64
// Progress reporting interval - report every 1MB of data
var lastReportedMB float64
const reportIntervalMB = 1.0
for {
select {
case <-ctx.Done():
return totalBytesWritten, fmt.Errorf("copy cancelled: %w", ctx.Err())
default:
}
if config.IsShouldShutdown() {
return totalBytesWritten, fmt.Errorf("copy cancelled due to shutdown")
}
bytesRead, readErr := src.Read(buf)
if bytesRead > 0 {
bytesWritten, writeErr := dst.Write(buf[0:bytesRead])
if bytesWritten < 0 || bytesRead < bytesWritten {
bytesWritten = 0
if writeErr == nil {
writeErr = fmt.Errorf("invalid write result")
}
}
if writeErr != nil {
return totalBytesWritten, writeErr
}
if bytesRead != bytesWritten {
return totalBytesWritten, io.ErrShortWrite
}
totalBytesWritten += int64(bytesWritten)
// Report progress based on total size
if backupProgressListener != nil {
currentSizeMB := float64(totalBytesWritten) / (1024 * 1024)
// Only report if we've written at least 1MB more data than last report
if currentSizeMB >= lastReportedMB+reportIntervalMB {
backupProgressListener(currentSizeMB)
lastReportedMB = currentSizeMB
}
}
}
if readErr != nil {
if readErr != io.EOF {
return totalBytesWritten, readErr
}
break
}
}
return totalBytesWritten, nil
}
// containsIgnoreCase checks if a string contains a substring, ignoring case
func containsIgnoreCase(str, substr string) bool {
return strings.Contains(strings.ToLower(str), strings.ToLower(substr))
}
// createTempPgpassFile creates a temporary .pgpass file with the given password
func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
pgConfig *pgtypes.PostgresqlDatabase,
password string,
) (string, error) {
if pgConfig == nil || password == "" {
return "", nil
}
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
pgConfig.Host,
pgConfig.Port,
pgConfig.Username,
password,
)
// it always create unique directory like /tmp/pgpass-1234567890
tempDir, err := os.MkdirTemp("", "pgpass")
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}
pgpassFile := filepath.Join(tempDir, ".pgpass")
err = os.WriteFile(pgpassFile, []byte(pgpassContent), 0600)
if err != nil {
return "", fmt.Errorf("failed to write temporary .pgpass file: %w", err)
}
return pgpassFile, nil
}

View File

@@ -0,0 +1,20 @@
package usecases_postgresql
import "io"
// CountingWriter wraps an io.Writer and counts the bytes written to it
type CountingWriter struct {
writer io.Writer
bytesWritten int64
}
func (cw *CountingWriter) Write(p []byte) (n int, err error) {
n, err = cw.writer.Write(p)
cw.bytesWritten += int64(n)
return n, err
}
// GetBytesWritten returns the total number of bytes written
func (cw *CountingWriter) GetBytesWritten() int64 {
return cw.bytesWritten
}

View File

@@ -0,0 +1,365 @@
package databases
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type DatabaseController struct {
databaseService *DatabaseService
userService *users.UserService
}
func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/databases/create", c.CreateDatabase)
router.POST("/databases/update", c.UpdateDatabase)
router.DELETE("/databases/:id", c.DeleteDatabase)
router.GET("/databases/:id", c.GetDatabase)
router.GET("/databases", c.GetDatabases)
router.POST("/databases/:id/test-connection", c.TestDatabaseConnection)
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
router.GET("/databases/storage/:id/is-using", c.IsStorageUsing)
}
// CreateDatabase
// @Summary Create a new database
// @Description Create a new database configuration
// @Tags databases
// @Accept json
// @Produce json
// @Param request body Database true "Database creation data"
// @Success 201 {object} Database
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/create [post]
func (c *DatabaseController) CreateDatabase(ctx *gin.Context) {
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.databaseService.CreateDatabase(user, &request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusCreated, request)
}
// UpdateDatabase
// @Summary Update a database
// @Description Update an existing database configuration
// @Tags databases
// @Accept json
// @Produce json
// @Param request body Database true "Database update data"
// @Success 200 {object} Database
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/update [post]
func (c *DatabaseController) UpdateDatabase(ctx *gin.Context) {
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.databaseService.UpdateDatabase(user, &request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, request)
}
// DeleteDatabase
// @Summary Delete a database
// @Description Delete a database configuration
// @Tags databases
// @Param id path string true "Database ID"
// @Success 204
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/{id} [delete]
func (c *DatabaseController) DeleteDatabase(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.databaseService.DeleteDatabase(user, id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.Status(http.StatusNoContent)
}
// GetDatabase
// @Summary Get a database
// @Description Get a database configuration by ID
// @Tags databases
// @Produce json
// @Param id path string true "Database ID"
// @Success 200 {object} Database
// @Failure 400
// @Failure 401
// @Router /databases/{id} [get]
func (c *DatabaseController) GetDatabase(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
database, err := c.databaseService.GetDatabase(user, id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, database)
}
// GetDatabases
// @Summary Get databases
// @Description Get all databases for the authenticated user
// @Tags databases
// @Produce json
// @Success 200 {array} Database
// @Failure 401
// @Failure 500
// @Router /databases [get]
func (c *DatabaseController) GetDatabases(ctx *gin.Context) {
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
databases, err := c.databaseService.GetDatabasesByUser(user)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, databases)
}
// TestDatabaseConnection
// @Summary Test database connection
// @Description Test connection to an existing database configuration
// @Tags databases
// @Param id path string true "Database ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/{id}/test-connection [post]
func (c *DatabaseController) TestDatabaseConnection(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.databaseService.TestDatabaseConnection(user, id); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "connection successful"})
}
// TestDatabaseConnectionDirect
// @Summary Test database connection directly
// @Description Test connection to a database configuration without saving it
// @Tags databases
// @Accept json
// @Param request body Database true "Database configuration to test"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /databases/test-connection-direct [post]
func (c *DatabaseController) TestDatabaseConnectionDirect(ctx *gin.Context) {
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// Set user ID for validation purposes
request.UserID = user.ID
if err := c.databaseService.TestDatabaseConnectionDirect(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "connection successful"})
}
// IsNotifierUsing
// @Summary Check if notifier is being used
// @Description Check if a notifier is currently being used by any database
// @Tags databases
// @Produce json
// @Param id path string true "Notifier ID"
// @Success 200 {object} map[string]bool
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/notifier/{id}/is-using [get]
func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
_, err = c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
isUsing, err := c.databaseService.IsNotifierUsing(id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
}
// IsStorageUsing
// @Summary Check if storage is being used
// @Description Check if a storage is currently being used by any database
// @Tags databases
// @Produce json
// @Param id path string true "Storage ID"
// @Success 200 {object} map[string]bool
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/storage/{id}/is-using [get]
func (c *DatabaseController) IsStorageUsing(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
_, err = c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
isUsing, err := c.databaseService.IsStorageUsing(id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
}

View File

@@ -0,0 +1,136 @@
package postgresql
import (
"context"
"errors"
"fmt"
"postgresus-backend/internal/util/logger"
"postgresus-backend/internal/util/tools"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var log = logger.GetLogger()
type PostgresqlDatabase struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
DatabaseID *uuid.UUID `json:"databaseId" gorm:"type:uuid;column:database_id"`
RestoreID *uuid.UUID `json:"restoreId" gorm:"type:uuid;column:restore_id"`
Version tools.PostgresqlVersion `json:"version" gorm:"type:text;not null"`
// connection data
Host string `json:"host" gorm:"type:text;not null"`
Port int `json:"port" gorm:"type:int;not null"`
Username string `json:"username" gorm:"type:text;not null"`
Password string `json:"password" gorm:"type:text;not null"`
Database *string `json:"database" gorm:"type:text"`
IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"`
CpuCount int `json:"cpuCount" gorm:"type:int;not null"`
}
func (p *PostgresqlDatabase) TableName() string {
return "postgresql_databases"
}
func (p *PostgresqlDatabase) Validate() error {
if p.Version == "" {
return errors.New("version is required")
}
if p.Host == "" {
return errors.New("host is required")
}
if p.Port == 0 {
return errors.New("port is required")
}
if p.Username == "" {
return errors.New("username is required")
}
if p.Password == "" {
return errors.New("password is required")
}
return nil
}
func (p *PostgresqlDatabase) TestConnection() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return testSingleDatabaseConnection(ctx, p)
}
// testSingleDatabaseConnection tests connection to a specific database for pg_dump
func testSingleDatabaseConnection(ctx context.Context, p *PostgresqlDatabase) error {
// For single database backup, we need to connect to the specific database
if p.Database == nil || *p.Database == "" {
return errors.New("database name is required for single database backup (pg_dump)")
}
// Build connection string for the specific database
connStr := buildConnectionStringForDB(p, *p.Database)
// Test connection
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
// TODO make more readable errors:
// - handle wrong creds
// - handle wrong database name
// - handle wrong protocol
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
log.Error("Failed to close connection", "error", closeErr)
}
}()
// Test if we can perform basic operations (like pg_dump would need)
if err := testBasicOperations(ctx, conn, *p.Database); err != nil {
return fmt.Errorf("basic operations test failed for database '%s': %w", *p.Database, err)
}
return nil
}
// testBasicOperations tests basic operations that backup tools need
func testBasicOperations(ctx context.Context, conn *pgx.Conn, dbName string) error {
var hasCreatePriv bool
err := conn.QueryRow(ctx, "SELECT has_database_privilege(current_user, current_database(), 'CONNECT')").
Scan(&hasCreatePriv)
if err != nil {
return fmt.Errorf("cannot check database privileges: %w", err)
}
if !hasCreatePriv {
return fmt.Errorf("user does not have CONNECT privilege on database '%s'", dbName)
}
return nil
}
// buildConnectionStringForDB builds connection string for specific database
func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string {
sslMode := "disable"
if p.IsHttps {
sslMode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
p.Host,
p.Port,
p.Username,
p.Password,
dbName,
sslMode,
)
}

View File

@@ -0,0 +1,23 @@
package databases
import "postgresus-backend/internal/features/users"
var databaseRepository = &DatabaseRepository{}
var databaseService = &DatabaseService{
databaseRepository,
nil,
}
var databaseController = &DatabaseController{
databaseService,
users.GetUserService(),
}
func GetDatabaseService() *DatabaseService {
return databaseService
}
func GetDatabaseController() *DatabaseController {
return databaseController
}

View File

@@ -0,0 +1 @@
package databases

View File

@@ -0,0 +1,62 @@
package databases
import "time"
type DatabaseType string
const (
DatabaseTypePostgres DatabaseType = "POSTGRES"
)
type Period string
const (
PeriodDay Period = "DAY"
PeriodWeek Period = "WEEK"
PeriodMonth Period = "MONTH"
Period3Month Period = "3_MONTH"
Period6Month Period = "6_MONTH"
PeriodYear Period = "YEAR"
Period2Years Period = "2_YEARS"
Period3Years Period = "3_YEARS"
Period4Years Period = "4_YEARS"
Period5Years Period = "5_YEARS"
PeriodForever Period = "FOREVER"
)
// ToDuration converts Period to time.Duration
func (p Period) ToDuration() time.Duration {
switch p {
case PeriodDay:
return 24 * time.Hour
case PeriodWeek:
return 7 * 24 * time.Hour
case PeriodMonth:
return 30 * 24 * time.Hour
case Period3Month:
return 90 * 24 * time.Hour
case Period6Month:
return 180 * 24 * time.Hour
case PeriodYear:
return 365 * 24 * time.Hour
case Period2Years:
return 2 * 365 * 24 * time.Hour
case Period3Years:
return 3 * 365 * 24 * time.Hour
case Period4Years:
return 4 * 365 * 24 * time.Hour
case Period5Years:
return 5 * 365 * 24 * time.Hour
case PeriodForever:
return 0
default:
panic("unknown period: " + string(p))
}
}
type BackupNotificationType string
const (
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
)

View File

@@ -0,0 +1,15 @@
package databases
import "github.com/google/uuid"
type DatabaseValidator interface {
Validate() error
}
type DatabaseConnector interface {
TestConnection() error
}
type DatabaseStorageChangeListener interface {
OnBeforeDbStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
}

View File

@@ -0,0 +1,116 @@
package databases
import (
"errors"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/intervals"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Database struct {
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"`
Name string `json:"name" gorm:"column:name;type:text;not null"`
Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"`
StorePeriod Period `json:"storePeriod" gorm:"column:store_period;type:text;not null"`
BackupIntervalID uuid.UUID `json:"backupIntervalId" gorm:"column:backup_interval_id;type:uuid;not null"`
BackupInterval *intervals.Interval `json:"backupInterval,omitempty" gorm:"foreignKey:BackupIntervalID"`
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:DatabaseID"`
Storage storages.Storage `json:"storage" gorm:"foreignKey:StorageID"`
StorageID uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;not null"`
Notifiers []notifiers.Notifier `json:"notifiers" gorm:"many2many:database_notifiers;"`
SendNotificationsOn []BackupNotificationType `json:"sendNotificationsOn" gorm:"-"`
SendNotificationsOnString string `json:"-" gorm:"column:send_notifications_on;type:text;not null"`
// these fields are not reliable, but
// they are used for pretty UI
LastBackupTime *time.Time `json:"lastBackupTime,omitempty" gorm:"column:last_backup_time;type:timestamp with time zone"`
LastBackupErrorMessage *string `json:"lastBackupErrorMessage,omitempty" gorm:"column:last_backup_error_message;type:text"`
}
func (d *Database) BeforeSave(tx *gorm.DB) error {
// Convert SendNotificationsOn array to string
if len(d.SendNotificationsOn) > 0 {
notificationTypes := make([]string, len(d.SendNotificationsOn))
for i, notificationType := range d.SendNotificationsOn {
notificationTypes[i] = string(notificationType)
}
d.SendNotificationsOnString = strings.Join(notificationTypes, ",")
} else {
d.SendNotificationsOnString = ""
}
return nil
}
func (d *Database) AfterFind(tx *gorm.DB) error {
// Convert SendNotificationsOnString to array
if d.SendNotificationsOnString != "" {
notificationTypes := strings.Split(d.SendNotificationsOnString, ",")
d.SendNotificationsOn = make([]BackupNotificationType, len(notificationTypes))
for i, notificationType := range notificationTypes {
d.SendNotificationsOn[i] = BackupNotificationType(notificationType)
}
} else {
d.SendNotificationsOn = []BackupNotificationType{}
}
return nil
}
func (d *Database) Validate() error {
if d.Name == "" {
return errors.New("name is required")
}
// Backup interval is required either as ID or as object
if d.BackupIntervalID == uuid.Nil && d.BackupInterval == nil {
return errors.New("backup interval is required")
}
if d.StorePeriod == "" {
return errors.New("store period is required")
}
if d.Postgresql.CpuCount == 0 {
return errors.New("cpu count is required")
}
switch d.Type {
case DatabaseTypePostgres:
return d.Postgresql.Validate()
default:
return errors.New("invalid database type: " + string(d.Type))
}
}
func (d *Database) ValidateUpdate(old, new Database) error {
if old.Type != new.Type {
return errors.New("database type is not allowed to change")
}
return nil
}
func (d *Database) TestConnection() error {
return d.getSpecificDatabase().TestConnection()
}
func (d *Database) getSpecificDatabase() DatabaseConnector {
switch d.Type {
case DatabaseTypePostgres:
return d.Postgresql
}
panic("invalid database type: " + string(d.Type))
}

View File

@@ -0,0 +1,191 @@
package databases
import (
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type DatabaseRepository struct{}
func (r *DatabaseRepository) Save(database *Database) error {
db := storage.GetDb()
isNew := database.ID == uuid.Nil
if isNew {
database.ID = uuid.New()
}
database.StorageID = database.Storage.ID
return db.Transaction(func(tx *gorm.DB) error {
if database.BackupInterval != nil {
if database.BackupInterval.ID == uuid.Nil {
if err := tx.Create(database.BackupInterval).Error; err != nil {
return err
}
database.BackupIntervalID = database.BackupInterval.ID
} else {
if err := tx.Save(database.BackupInterval).Error; err != nil {
return err
}
database.BackupIntervalID = database.BackupInterval.ID
}
}
switch database.Type {
case DatabaseTypePostgres:
if database.Postgresql != nil {
database.Postgresql.DatabaseID = &database.ID
}
}
if isNew {
if err := tx.Create(database).
Omit("Postgresql", "Storage", "Notifiers", "BackupInterval").
Error; err != nil {
return err
}
} else {
if err := tx.Save(database).
Omit("Postgresql", "Storage", "Notifiers", "BackupInterval").
Error; err != nil {
return err
}
}
// Save the specific database type
switch database.Type {
case DatabaseTypePostgres:
if database.Postgresql != nil {
database.Postgresql.DatabaseID = &database.ID
if database.Postgresql.ID == uuid.Nil {
database.Postgresql.ID = uuid.New()
if err := tx.Create(database.Postgresql).Error; err != nil {
return err
}
} else {
if err := tx.Save(database.Postgresql).Error; err != nil {
return err
}
}
}
}
if err := tx.Model(database).Association("Notifiers").Replace(database.Notifiers); err != nil {
return err
}
return nil
})
}
func (r *DatabaseRepository) FindByID(id uuid.UUID) (*Database, error) {
var database Database
if err := storage.
GetDb().
Preload("BackupInterval").
Preload("Postgresql").
Preload("Storage").
Preload("Notifiers").
Where("id = ?", id).
First(&database).Error; err != nil {
return nil, err
}
return &database, nil
}
func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error) {
var databases []*Database
if err := storage.
GetDb().
Preload("BackupInterval").
Preload("Postgresql").
Preload("Storage").
Preload("Notifiers").
Where("user_id = ?", userID).
Find(&databases).Error; err != nil {
return nil, err
}
return databases, nil
}
func (r *DatabaseRepository) Delete(id uuid.UUID) error {
db := storage.GetDb()
return db.Transaction(func(tx *gorm.DB) error {
var database Database
if err := tx.Where("id = ?", id).First(&database).Error; err != nil {
return err
}
if err := tx.Model(&database).Association("Notifiers").Clear(); err != nil {
return err
}
switch database.Type {
case DatabaseTypePostgres:
if err := tx.Where("database_id = ?", id).Delete(&postgresql.PostgresqlDatabase{}).Error; err != nil {
return err
}
}
if err := tx.Delete(&Database{}, id).Error; err != nil {
return err
}
return nil
})
}
func (r *DatabaseRepository) IsNotifierUsing(notifierID uuid.UUID) (bool, error) {
var count int64
if err := storage.
GetDb().
Table("database_notifiers").
Where("notifier_id = ?", notifierID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *DatabaseRepository) IsStorageUsing(storageID uuid.UUID) (bool, error) {
var count int64
if err := storage.
GetDb().
Table("databases").
Where("storage_id = ?", storageID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *DatabaseRepository) GetAllDatabases() ([]*Database, error) {
var databases []*Database
if err := storage.
GetDb().
Preload("BackupInterval").
Preload("Postgresql").
Preload("Storage").
Preload("Notifiers").
Find(&databases).Error; err != nil {
return nil, err
}
return databases, nil
}

View File

@@ -0,0 +1,183 @@
package databases
import (
"errors"
"fmt"
users_models "postgresus-backend/internal/features/users/models"
"time"
"github.com/google/uuid"
)
type DatabaseService struct {
dbRepository *DatabaseRepository
dbStorageChangeListener DatabaseStorageChangeListener
}
func (s *DatabaseService) SetDatabaseStorageChangeListener(
dbStorageChangeListener DatabaseStorageChangeListener,
) {
s.dbStorageChangeListener = dbStorageChangeListener
}
func (s *DatabaseService) CreateDatabase(
user *users_models.User,
database *Database,
) error {
database.UserID = user.ID
if err := database.Validate(); err != nil {
return err
}
return s.dbRepository.Save(database)
}
func (s *DatabaseService) UpdateDatabase(
user *users_models.User,
database *Database,
) error {
if database.ID == uuid.Nil {
return errors.New("database ID is required for update")
}
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return err
}
if existingDatabase.UserID != user.ID {
return errors.New("you have not access to this database")
}
// Validate the update
if err := database.ValidateUpdate(*existingDatabase, *database); err != nil {
return err
}
if err := database.Validate(); err != nil {
return err
}
if existingDatabase.Storage.ID != database.Storage.ID {
fmt.Println("OnBeforeDbStorageChange")
err := s.dbStorageChangeListener.OnBeforeDbStorageChange(
existingDatabase.ID,
database.StorageID,
)
if err != nil {
return err
}
}
return s.dbRepository.Save(database)
}
func (s *DatabaseService) DeleteDatabase(
user *users_models.User,
id uuid.UUID,
) error {
existingDatabase, err := s.dbRepository.FindByID(id)
if err != nil {
return err
}
if existingDatabase.UserID != user.ID {
return errors.New("you have not access to this database")
}
return s.dbRepository.Delete(id)
}
func (s *DatabaseService) GetDatabase(
user *users_models.User,
id uuid.UUID,
) (*Database, error) {
database, err := s.dbRepository.FindByID(id)
if err != nil {
return nil, err
}
if database.UserID != user.ID {
return nil, errors.New("you have not access to this database")
}
return database, nil
}
func (s *DatabaseService) GetDatabasesByUser(
user *users_models.User,
) ([]*Database, error) {
return s.dbRepository.FindByUserID(user.ID)
}
func (s *DatabaseService) IsNotifierUsing(notifierID uuid.UUID) (bool, error) {
return s.dbRepository.IsNotifierUsing(notifierID)
}
func (s *DatabaseService) IsStorageUsing(storageID uuid.UUID) (bool, error) {
return s.dbRepository.IsStorageUsing(storageID)
}
func (s *DatabaseService) TestDatabaseConnection(
user *users_models.User,
databaseID uuid.UUID,
) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
if database.UserID != user.ID {
return errors.New("you have not access to this database")
}
err = database.TestConnection()
if err != nil {
lastSaveError := err.Error()
database.LastBackupErrorMessage = &lastSaveError
return err
}
database.LastBackupErrorMessage = nil
return s.dbRepository.Save(database)
}
func (s *DatabaseService) TestDatabaseConnectionDirect(
database *Database,
) error {
return database.TestConnection()
}
func (s *DatabaseService) GetDatabaseByID(
id uuid.UUID,
) (*Database, error) {
return s.dbRepository.FindByID(id)
}
func (s *DatabaseService) GetAllDatabases() ([]*Database, error) {
return s.dbRepository.GetAllDatabases()
}
func (s *DatabaseService) SetBackupError(databaseID uuid.UUID, errorMessage string) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.LastBackupErrorMessage = &errorMessage
return s.dbRepository.Save(database)
}
func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime time.Time) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.LastBackupTime = &backupTime
database.LastBackupErrorMessage = nil // Clear any previous error
return s.dbRepository.Save(database)
}

View File

@@ -0,0 +1,10 @@
package intervals
type IntervalType string
const (
IntervalHourly IntervalType = "HOURLY"
IntervalDaily IntervalType = "DAILY"
IntervalWeekly IntervalType = "WEEKLY"
IntervalMonthly IntervalType = "MONTHLY"
)

View File

@@ -0,0 +1,192 @@
package intervals
import (
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Interval struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
Interval IntervalType `json:"interval" gorm:"type:text;not null"`
TimeOfDay *string `json:"timeOfDay" gorm:"type:text;"`
// only for WEEKLY
Weekday *int `json:"weekday,omitempty" gorm:"type:int"`
// only for MONTHLY
DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"type:int"`
}
func (i *Interval) BeforeSave(tx *gorm.DB) error {
return i.Validate()
}
func (i *Interval) Validate() error {
// for daily, weekly and monthly intervals time of day is required
if (i.Interval == IntervalDaily || i.Interval == IntervalWeekly || i.Interval == IntervalMonthly) &&
i.TimeOfDay == nil {
return errors.New("time of day is required for daily, weekly and monthly intervals")
}
// for weekly interval weekday is required
if i.Interval == IntervalWeekly && i.Weekday == nil {
return errors.New("weekday is required for weekly intervals")
}
// for monthly interval day of month is required
if i.Interval == IntervalMonthly && i.DayOfMonth == nil {
return errors.New("day of month is required for monthly intervals")
}
return nil
}
// ShouldTriggerBackup checks if a backup should be triggered based on the interval and last backup time
func (i *Interval) ShouldTriggerBackup(now time.Time, lastBackupTime *time.Time) bool {
// If no backup has been made yet, trigger immediately
if lastBackupTime == nil {
return true
}
switch i.Interval {
case IntervalHourly:
return now.Sub(*lastBackupTime) >= time.Hour
case IntervalDaily:
return i.shouldTriggerDaily(now, *lastBackupTime)
case IntervalWeekly:
return i.shouldTriggerWeekly(now, *lastBackupTime)
case IntervalMonthly:
return i.shouldTriggerMonthly(now, *lastBackupTime)
default:
return false
}
}
// daily trigger: calendar-based if TimeOfDay set, otherwise next calendar day
func (i *Interval) shouldTriggerDaily(now, lastBackup time.Time) bool {
if i.TimeOfDay != nil {
target, err := time.Parse("15:04", *i.TimeOfDay)
if err == nil {
todayTarget := time.Date(
now.Year(),
now.Month(),
now.Day(),
target.Hour(),
target.Minute(),
0,
0,
now.Location(),
)
// if it's past today's target time and we haven't backed up today
if now.After(todayTarget) && !isSameDay(lastBackup, now) {
return true
}
// if it's exactly the target time and we haven't backed up today
if now.Equal(todayTarget) && !isSameDay(lastBackup, now) {
return true
}
// if it's before today's target time, don't trigger yet
if now.Before(todayTarget) {
return false
}
}
}
// no TimeOfDay: if it's a new calendar day
return !isSameDay(lastBackup, now)
}
// weekly trigger: on specified weekday/calendar week, otherwise ≥7 days
func (i *Interval) shouldTriggerWeekly(now, lastBackup time.Time) bool {
if i.Weekday != nil {
targetWd := time.Weekday(*i.Weekday)
startOfWeek := getStartOfWeek(now)
// today is target weekday and no backup this week
if now.Weekday() == targetWd && lastBackup.Before(startOfWeek) {
if i.TimeOfDay != nil {
t, err := time.Parse("15:04", *i.TimeOfDay)
if err == nil {
todayT := time.Date(
now.Year(),
now.Month(),
now.Day(),
t.Hour(),
t.Minute(),
0,
0,
now.Location(),
)
return now.After(todayT) || now.Equal(todayT)
}
}
return true
}
// passed this week's slot and missed entirely
targetThisWeek := startOfWeek.AddDate(0, 0, int(targetWd))
if now.After(targetThisWeek) && lastBackup.Before(startOfWeek) {
return true
}
return false
}
// no Weekday: generic 7-day interval
return now.Sub(lastBackup) >= 7*24*time.Hour
}
// monthly trigger: on specified day/calendar month, otherwise next calendar month
func (i *Interval) shouldTriggerMonthly(now, lastBackup time.Time) bool {
if i.DayOfMonth != nil {
day := *i.DayOfMonth
startOfMonth := getStartOfMonth(now)
// today is target day and no backup this month
if now.Day() == day && lastBackup.Before(startOfMonth) {
if i.TimeOfDay != nil {
t, err := time.Parse("15:04", *i.TimeOfDay)
if err == nil {
todayT := time.Date(
now.Year(),
now.Month(),
now.Day(),
t.Hour(),
t.Minute(),
0,
0,
now.Location(),
)
return now.After(todayT) || now.Equal(todayT)
}
}
return true
}
// passed this month's slot and missed entirely
if now.Day() > day && lastBackup.Before(startOfMonth) {
return true
}
return false
}
// no DayOfMonth: if we're in a new calendar month
return lastBackup.Before(getStartOfMonth(now))
}
func isSameDay(a, b time.Time) bool {
y1, m1, d1 := a.Date()
y2, m2, d2 := b.Date()
return y1 == y2 && m1 == m2 && d1 == d2
}
func getStartOfWeek(t time.Time) time.Time {
wd := int(t.Weekday())
if wd == 0 {
wd = 7
}
return time.Date(t.Year(), t.Month(), t.Day()-wd+1, 0, 0, 0, 0, t.Location())
}
func getStartOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}

View File

@@ -0,0 +1,337 @@
package intervals
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestInterval_ShouldTriggerBackup_Hourly(t *testing.T) {
interval := &Interval{
ID: uuid.New(),
Interval: IntervalHourly,
}
baseTime := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) {
should := interval.ShouldTriggerBackup(baseTime, nil)
assert.True(t, should)
})
t.Run("Last backup 59 minutes ago: Do not trigger backup", func(t *testing.T) {
lastBackup := baseTime.Add(-59 * time.Minute)
should := interval.ShouldTriggerBackup(baseTime, &lastBackup)
assert.False(t, should)
})
t.Run("Last backup exactly 1 hour ago: Trigger backup", func(t *testing.T) {
lastBackup := baseTime.Add(-1 * time.Hour)
should := interval.ShouldTriggerBackup(baseTime, &lastBackup)
assert.True(t, should)
})
t.Run("Last backup 2 hours ago: Trigger backup", func(t *testing.T) {
lastBackup := baseTime.Add(-2 * time.Hour)
should := interval.ShouldTriggerBackup(baseTime, &lastBackup)
assert.True(t, should)
})
}
func TestInterval_ShouldTriggerBackup_Daily(t *testing.T) {
timeOfDay := "09:00"
interval := &Interval{
ID: uuid.New(),
Interval: IntervalDaily,
TimeOfDay: &timeOfDay,
}
// Base time: January 15, 2024
baseDate := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) {
now := baseDate.Add(10 * time.Hour) // 10:00 AM
should := interval.ShouldTriggerBackup(now, nil)
assert.True(t, should)
})
t.Run("Today 08:59, no backup today: Do not trigger backup", func(t *testing.T) {
now := time.Date(2024, 1, 15, 8, 59, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.False(t, should)
})
t.Run("Today exactly 09:00, no backup today: Trigger backup", func(t *testing.T) {
now := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
})
t.Run("Today 09:01, no backup today: Trigger backup", func(t *testing.T) {
now := time.Date(2024, 1, 15, 9, 1, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
})
t.Run("Backup earlier today at 09:00: Do not trigger another backup", func(t *testing.T) {
now := time.Date(2024, 1, 15, 15, 0, 0, 0, time.UTC) // 3 PM
lastBackup := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC) // Today at 9 AM
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.False(t, should)
})
t.Run(
"Backup yesterday at correct time: Trigger backup today at or after 09:00",
func(t *testing.T) {
now := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday at 9 AM
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
}
func TestInterval_ShouldTriggerBackup_Weekly(t *testing.T) {
timeOfDay := "15:00"
weekday := 3 // Wednesday (0=Sunday, 1=Monday, ..., 3=Wednesday)
interval := &Interval{
ID: uuid.New(),
Interval: IntervalWeekly,
TimeOfDay: &timeOfDay,
Weekday: &weekday,
}
// Base time: Wednesday, January 17, 2024 (to ensure we're on Wednesday)
wednesday := time.Date(2024, 1, 17, 0, 0, 0, 0, time.UTC)
t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) {
now := wednesday.Add(16 * time.Hour) // 4 PM Wednesday
should := interval.ShouldTriggerBackup(now, nil)
assert.True(t, should)
})
t.Run(
"Today Wednesday at 14:59, no backup this week: Do not trigger backup",
func(t *testing.T) {
now := time.Date(2024, 1, 17, 14, 59, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.False(t, should)
},
)
t.Run(
"Today Wednesday at exactly 15:00, no backup this week: Trigger backup",
func(t *testing.T) {
now := time.Date(2024, 1, 17, 15, 0, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
t.Run("Today Wednesday at 15:01, no backup this week: Trigger backup", func(t *testing.T) {
now := time.Date(2024, 1, 17, 15, 1, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
})
t.Run(
"Backup already done this week (Wednesday 15:00): Do not trigger again",
func(t *testing.T) {
now := time.Date(2024, 1, 18, 10, 0, 0, 0, time.UTC) // Thursday
lastBackup := time.Date(2024, 1, 17, 15, 0, 0, 0, time.UTC) // Wednesday this week
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.False(t, should)
},
)
t.Run(
"Backup missed yesterday (it's Thursday): Trigger backup immediately",
func(t *testing.T) {
now := time.Date(2024, 1, 18, 10, 0, 0, 0, time.UTC) // Thursday
lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
t.Run(
"Backup last week: Trigger backup at this week's scheduled time or immediately if already missed",
func(t *testing.T) {
now := time.Date(
2024,
1,
17,
15,
0,
0,
0,
time.UTC,
) // Wednesday at scheduled time
lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
}
func TestInterval_ShouldTriggerBackup_Monthly(t *testing.T) {
timeOfDay := "08:00"
dayOfMonth := 10
interval := &Interval{
ID: uuid.New(),
Interval: IntervalMonthly,
TimeOfDay: &timeOfDay,
DayOfMonth: &dayOfMonth,
}
t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) {
now := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
should := interval.ShouldTriggerBackup(now, nil)
assert.True(t, should)
})
t.Run(
"Today is the 10th at 07:59, no backup this month: Do not trigger backup",
func(t *testing.T) {
now := time.Date(2024, 1, 10, 7, 59, 0, 0, time.UTC)
lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.False(t, should)
},
)
t.Run(
"Today is the 10th exactly 08:00, no backup this month: Trigger backup",
func(t *testing.T) {
now := time.Date(2024, 1, 10, 8, 0, 0, 0, time.UTC)
lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
t.Run(
"Today is the 10th after 08:00, no backup this month: Trigger backup",
func(t *testing.T) {
now := time.Date(2024, 1, 10, 8, 1, 0, 0, time.UTC)
lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
t.Run(
"Today is the 11th, backup missed on the 10th: Trigger backup immediately",
func(t *testing.T) {
now := time.Date(2024, 1, 11, 10, 0, 0, 0, time.UTC)
lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
t.Run("Backup already performed this month: Do not trigger again", func(t *testing.T) {
now := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
lastBackup := time.Date(2024, 1, 10, 8, 0, 0, 0, time.UTC) // This month
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.False(t, should)
})
t.Run(
"Backup performed last month on schedule: Trigger backup this month at or after scheduled date/time",
func(t *testing.T) {
now := time.Date(2024, 1, 10, 8, 0, 0, 0, time.UTC)
lastBackup := time.Date(
2023,
12,
10,
8,
0,
0,
0,
time.UTC,
) // Previous month at scheduled time
should := interval.ShouldTriggerBackup(now, &lastBackup)
assert.True(t, should)
},
)
}
func TestInterval_Validate(t *testing.T) {
t.Run("Daily interval requires time of day", func(t *testing.T) {
interval := &Interval{
ID: uuid.New(),
Interval: IntervalDaily,
}
err := interval.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "time of day is required")
})
t.Run("Weekly interval requires weekday", func(t *testing.T) {
timeOfDay := "09:00"
interval := &Interval{
ID: uuid.New(),
Interval: IntervalWeekly,
TimeOfDay: &timeOfDay,
}
err := interval.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "weekday is required")
})
t.Run("Monthly interval requires day of month", func(t *testing.T) {
timeOfDay := "09:00"
interval := &Interval{
ID: uuid.New(),
Interval: IntervalMonthly,
TimeOfDay: &timeOfDay,
}
err := interval.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "day of month is required")
})
t.Run("Hourly interval is valid without additional fields", func(t *testing.T) {
interval := &Interval{
ID: uuid.New(),
Interval: IntervalHourly,
}
err := interval.Validate()
assert.NoError(t, err)
})
t.Run("Valid weekly interval", func(t *testing.T) {
timeOfDay := "09:00"
weekday := 1
interval := &Interval{
ID: uuid.New(),
Interval: IntervalWeekly,
TimeOfDay: &timeOfDay,
Weekday: &weekday,
}
err := interval.Validate()
assert.NoError(t, err)
})
t.Run("Valid monthly interval", func(t *testing.T) {
timeOfDay := "09:00"
dayOfMonth := 15
interval := &Interval{
ID: uuid.New(),
Interval: IntervalMonthly,
TimeOfDay: &timeOfDay,
DayOfMonth: &dayOfMonth,
}
err := interval.Validate()
assert.NoError(t, err)
})
}

View File

@@ -0,0 +1,225 @@
package notifiers
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NotifierController struct {
notifierService *NotifierService
userService *users.UserService
}
func (c *NotifierController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/notifiers", c.SaveNotifier)
router.GET("/notifiers", c.GetNotifiers)
router.GET("/notifiers/:id", c.GetNotifier)
router.DELETE("/notifiers/:id", c.DeleteNotifier)
router.POST("/notifiers/:id/test", c.SendTestNotification)
router.POST("/notifiers/direct-test", c.SendTestNotificationDirect)
}
// SaveNotifier
// @Summary Save a notifier
// @Description Create or update a notifier
// @Tags notifiers
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param notifier body Notifier true "Notifier data"
// @Success 200 {object} Notifier
// @Failure 400
// @Failure 401
// @Router /notifiers [post]
func (c *NotifierController) SaveNotifier(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var notifier Notifier
if err := ctx.ShouldBindJSON(&notifier); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := notifier.Validate(); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := c.notifierService.SaveNotifier(user, &notifier); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, notifier)
}
// GetNotifier
// @Summary Get a notifier by ID
// @Description Get a specific notifier by ID
// @Tags notifiers
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Notifier ID"
// @Success 200 {object} Notifier
// @Failure 400
// @Failure 401
// @Router /notifiers/{id} [get]
func (c *NotifierController) GetNotifier(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"})
return
}
notifier, err := c.notifierService.GetNotifier(user, id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, notifier)
}
// GetNotifiers
// @Summary Get all notifiers
// @Description Get all notifiers for the current user
// @Tags notifiers
// @Produce json
// @Param Authorization header string true "JWT token"
// @Success 200 {array} Notifier
// @Failure 401
// @Router /notifiers [get]
func (c *NotifierController) GetNotifiers(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
notifiers, err := c.notifierService.GetNotifiers(user)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, notifiers)
}
// DeleteNotifier
// @Summary Delete a notifier
// @Description Delete a notifier by ID
// @Tags notifiers
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Notifier ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /notifiers/{id} [delete]
func (c *NotifierController) DeleteNotifier(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"})
return
}
notifier, err := c.notifierService.GetNotifier(user, id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := c.notifierService.DeleteNotifier(user, notifier.ID); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "notifier deleted successfully"})
}
// SendTestNotification
// @Summary Send test notification
// @Description Send a test notification using the specified notifier
// @Tags notifiers
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Notifier ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /notifiers/{id}/test [post]
func (c *NotifierController) SendTestNotification(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"})
return
}
if err := c.notifierService.SendTestNotification(user, id); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "test notification sent successfully"})
}
// SendTestNotificationDirect
// @Summary Send test notification directly
// @Description Send a test notification using a notifier object provided in the request
// @Tags notifiers
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param notifier body Notifier true "Notifier data"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /notifiers/direct-test [post]
func (c *NotifierController) SendTestNotificationDirect(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var notifier Notifier
if err := ctx.ShouldBindJSON(&notifier); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// For direct test, associate with the current user
notifier.UserID = user.ID
if err := c.notifierService.SendTestNotificationToNotifier(&notifier); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "test notification sent successfully"})
}

View File

@@ -0,0 +1,20 @@
package notifiers
import "postgresus-backend/internal/features/users"
var notifierRepository = &NotifierRepository{}
var notifierService = &NotifierService{
notifierRepository,
}
var notifierController = &NotifierController{
notifierService,
users.GetUserService(),
}
func GetNotifierController() *NotifierController {
return notifierController
}
func GetNotifierService() *NotifierService {
return notifierService
}

View File

@@ -0,0 +1,8 @@
package notifiers
type NotifierType string
const (
NotifierTypeEmail NotifierType = "EMAIL"
NotifierTypeTelegram NotifierType = "TELEGRAM"
)

View File

@@ -0,0 +1,7 @@
package notifiers
type NotificationSender interface {
Send(heading string, message string) error
Validate() error
}

View File

@@ -0,0 +1,56 @@
package notifiers
import (
"errors"
"postgresus-backend/internal/features/notifiers/notifiers/email_notifier"
telegram_notifier "postgresus-backend/internal/features/notifiers/notifiers/telegram"
"github.com/google/uuid"
)
type Notifier struct {
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null;type:uuid;index"`
Name string `json:"name" gorm:"column:name;not null;type:varchar(255)"`
NotifierType NotifierType `json:"notifierType" gorm:"column:notifier_type;not null;type:varchar(50)"`
LastSendError *string `json:"lastSendError" gorm:"column:last_send_error;type:text"`
// specific notifier
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
}
func (n *Notifier) TableName() string {
return "notifiers"
}
func (n *Notifier) Validate() error {
if n.Name == "" {
return errors.New("name is required")
}
return n.getSpecificNotifier().Validate()
}
func (n *Notifier) Send(heading string, message string) error {
err := n.getSpecificNotifier().Send(heading, message)
if err != nil {
lastSendError := err.Error()
n.LastSendError = &lastSendError
} else {
n.LastSendError = nil
}
return err
}
func (n *Notifier) getSpecificNotifier() NotificationSender {
switch n.NotifierType {
case NotifierTypeTelegram:
return n.TelegramNotifier
case NotifierTypeEmail:
return n.EmailNotifier
default:
panic("unknown notifier type: " + string(n.NotifierType))
}
}

View File

@@ -0,0 +1,202 @@
package email_notifier
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"time"
"github.com/google/uuid"
)
const (
ImplicitTLSPort = 465
DefaultTimeout = 5 * time.Second
DefaultHelloName = "localhost"
MIMETypeHTML = "text/html"
MIMECharsetUTF8 = "UTF-8"
)
type EmailNotifier struct {
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;type:uuid;column:notifier_id"`
TargetEmail string `json:"targetEmail" gorm:"not null;type:varchar(255);column:target_email"`
SMTPHost string `json:"smtpHost" gorm:"not null;type:varchar(255);column:smtp_host"`
SMTPPort int `json:"smtpPort" gorm:"not null;column:smtp_port"`
SMTPUser string `json:"smtpUser" gorm:"not null;type:varchar(255);column:smtp_user"`
SMTPPassword string `json:"smtpPassword" gorm:"not null;type:varchar(255);column:smtp_password"`
}
func (e *EmailNotifier) TableName() string {
return "email_notifiers"
}
func (e *EmailNotifier) Validate() error {
if e.TargetEmail == "" {
return errors.New("target email is required")
}
if e.SMTPHost == "" {
return errors.New("SMTP host is required")
}
if e.SMTPPort == 0 {
return errors.New("SMTP port is required")
}
if e.SMTPUser == "" {
return errors.New("SMTP user is required")
}
if e.SMTPPassword == "" {
return errors.New("SMTP password is required")
}
return nil
}
func (e *EmailNotifier) Send(heading string, message string) error {
// Compose email
from := e.SMTPUser
to := []string{e.TargetEmail}
// Format the email content
subject := fmt.Sprintf("Subject: %s\r\n", heading)
mime := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
MIMETypeHTML,
MIMECharsetUTF8,
)
body := message
fromHeader := fmt.Sprintf("From: %s\r\n", from)
// Combine all parts of the email
emailContent := []byte(fromHeader + subject + mime + body)
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
timeout := DefaultTimeout
// Handle different port scenarios
if e.SMTPPort == ImplicitTLSPort {
// Implicit TLS (port 465)
// Set up TLS config
tlsConfig := &tls.Config{
ServerName: e.SMTPHost,
}
// Dial with timeout
dialer := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer func() {
_ = conn.Close()
}()
// Create SMTP client
client, err := smtp.NewClient(conn, e.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer func() {
_ = client.Quit()
}()
// Set up authentication
auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
// Set sender and recipients
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, recipient := range to {
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
}
// Send the email body
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = writer.Write(emailContent)
if err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
} else {
// STARTTLS (port 587) or other ports
// Set up authentication information
auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost)
// Create a custom dialer with timeout
dialer := &net.Dialer{Timeout: timeout}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
// Create client from connection
client, err := smtp.NewClient(conn, e.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer func() {
_ = client.Quit()
}()
// Send email using the client
if err := client.Hello(DefaultHelloName); err != nil {
return fmt.Errorf("SMTP hello failed: %w", err)
}
// Start TLS if available
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil {
return fmt.Errorf("STARTTLS failed: %w", err)
}
}
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, recipient := range to {
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = writer.Write(emailContent)
if err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return client.Quit()
}
}

View File

@@ -0,0 +1,75 @@
package telegram_notifier
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
)
type TelegramNotifier struct {
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
BotToken string `json:"botToken" gorm:"not null;column:bot_token"`
TargetChatID string `json:"targetChatId" gorm:"not null;column:target_chat_id"`
}
func (t *TelegramNotifier) TableName() string {
return "telegram_notifiers"
}
func (t *TelegramNotifier) Validate() error {
if t.BotToken == "" {
return errors.New("bot token is required")
}
if t.TargetChatID == "" {
return errors.New("target chat ID is required")
}
return nil
}
func (t *TelegramNotifier) Send(heading string, message string) error {
fullMessage := heading
if message != "" {
fullMessage = fmt.Sprintf("%s\n\n%s", heading, message)
}
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken)
data := url.Values{}
data.Set("chat_id", t.TargetChatID)
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML")
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send telegram message: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"telegram API returned non-OK status: %s. Error: %s",
resp.Status,
string(bodyBytes),
)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package notifiers
import (
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotifierRepository struct{}
func (r *NotifierRepository) Save(notifier *Notifier) error {
db := storage.GetDb()
return db.Transaction(func(tx *gorm.DB) error {
switch notifier.NotifierType {
case NotifierTypeTelegram:
if notifier.TelegramNotifier != nil {
notifier.TelegramNotifier.NotifierID = notifier.ID
}
case NotifierTypeEmail:
if notifier.EmailNotifier != nil {
notifier.EmailNotifier.NotifierID = notifier.ID
}
}
if notifier.ID == uuid.Nil {
if err := tx.Create(notifier).
Omit("TelegramNotifier", "EmailNotifier").
Error; err != nil {
return err
}
} else {
if err := tx.Save(notifier).
Omit("TelegramNotifier", "EmailNotifier").
Error; err != nil {
return err
}
}
switch notifier.NotifierType {
case NotifierTypeTelegram:
if notifier.TelegramNotifier != nil {
notifier.TelegramNotifier.NotifierID = notifier.ID // Ensure ID is set
if err := tx.Save(notifier.TelegramNotifier).Error; err != nil {
return err
}
}
case NotifierTypeEmail:
if notifier.EmailNotifier != nil {
notifier.EmailNotifier.NotifierID = notifier.ID // Ensure ID is set
if err := tx.Save(notifier.EmailNotifier).Error; err != nil {
return err
}
}
}
return nil
})
}
func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
var notifier Notifier
if err := storage.
GetDb().
Preload("TelegramNotifier").
Preload("EmailNotifier").
Where("id = ?", id).
First(&notifier).Error; err != nil {
return nil, err
}
return &notifier, nil
}
func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error) {
var notifiers []*Notifier
if err := storage.
GetDb().
Preload("TelegramNotifier").
Preload("EmailNotifier").
Where("user_id = ?", userID).
Find(&notifiers).Error; err != nil {
return nil, err
}
return notifiers, nil
}
func (r *NotifierRepository) Delete(notifier *Notifier) error {
return storage.GetDb().Transaction(func(tx *gorm.DB) error {
// Delete specific notifier based on type
switch notifier.NotifierType {
case NotifierTypeTelegram:
if notifier.TelegramNotifier != nil {
if err := tx.Delete(notifier.TelegramNotifier).Error; err != nil {
return err
}
}
case NotifierTypeEmail:
if notifier.EmailNotifier != nil {
if err := tx.Delete(notifier.EmailNotifier).Error; err != nil {
return err
}
}
}
// Delete the main notifier
return tx.Delete(notifier).Error
})
}

View File

@@ -0,0 +1,140 @@
package notifiers
import (
"errors"
users_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/util/logger"
"github.com/google/uuid"
)
var log = logger.GetLogger()
type NotifierService struct {
notifierRepository *NotifierRepository
}
func (s *NotifierService) SaveNotifier(
user *users_models.User,
notifier *Notifier,
) error {
if notifier.ID != uuid.Nil {
existingNotifier, err := s.notifierRepository.FindByID(notifier.ID)
if err != nil {
return err
}
if existingNotifier.UserID != user.ID {
return errors.New("you have not access to this notifier")
}
notifier.UserID = existingNotifier.UserID
} else {
notifier.UserID = user.ID
}
return s.notifierRepository.Save(notifier)
}
func (s *NotifierService) DeleteNotifier(
user *users_models.User,
notifierID uuid.UUID,
) error {
notifier, err := s.notifierRepository.FindByID(notifierID)
if err != nil {
return err
}
if notifier.UserID != user.ID {
return errors.New("you have not access to this notifier")
}
return s.notifierRepository.Delete(notifier)
}
func (s *NotifierService) GetNotifier(
user *users_models.User,
id uuid.UUID,
) (*Notifier, error) {
notifier, err := s.notifierRepository.FindByID(id)
if err != nil {
return nil, err
}
if notifier.UserID != user.ID {
return nil, errors.New("you have not access to this notifier")
}
return notifier, nil
}
func (s *NotifierService) GetNotifiers(
user *users_models.User,
) ([]*Notifier, error) {
return s.notifierRepository.FindByUserID(user.ID)
}
func (s *NotifierService) SendTestNotification(
user *users_models.User,
notifierID uuid.UUID,
) error {
notifier, err := s.notifierRepository.FindByID(notifierID)
if err != nil {
return err
}
if notifier.UserID != user.ID {
return errors.New("you have not access to this notifier")
}
err = notifier.Send("Test message", "This is a test message")
if err != nil {
return err
}
if err = s.notifierRepository.Save(notifier); err != nil {
return err
}
return nil
}
func (s *NotifierService) SendTestNotificationToNotifier(
notifier *Notifier,
) error {
return notifier.Send("Test message", "This is a test message")
}
func (s *NotifierService) SendNotification(
notifier *Notifier,
title string,
message string,
) {
// Truncate message to 2000 characters if it's too long
messageRunes := []rune(message)
if len(messageRunes) > 2000 {
message = string(messageRunes[:2000])
}
notifiedFromDb, err := s.notifierRepository.FindByID(notifier.ID)
if err != nil {
return
}
err = notifiedFromDb.Send(title, message)
if err != nil {
errMsg := err.Error()
notifiedFromDb.LastSendError = &errMsg
err = s.notifierRepository.Save(notifiedFromDb)
if err != nil {
log.Error("Failed to save notifier", "error", err)
}
}
notifiedFromDb.LastSendError = nil
err = s.notifierRepository.Save(notifiedFromDb)
if err != nil {
log.Error("Failed to save notifier", "error", err)
}
}

View File

@@ -0,0 +1,38 @@
package restores
import (
"postgresus-backend/internal/features/restores/enums"
"postgresus-backend/internal/util/logger"
)
var log = logger.GetLogger()
type RestoreBackgroundService struct {
restoreRepository *RestoreRepository
}
func (s *RestoreBackgroundService) Run() {
if err := s.failRestoresInProgress(); err != nil {
log.Error("Failed to fail restores in progress", "error", err)
panic(err)
}
}
func (s *RestoreBackgroundService) failRestoresInProgress() error {
restoresInProgress, err := s.restoreRepository.FindByStatus(enums.RestoreStatusInProgress)
if err != nil {
return err
}
for _, restore := range restoresInProgress {
failMessage := "Restore failed due to application restart"
restore.Status = enums.RestoreStatusFailed
restore.FailMessage = &failMessage
if err := s.restoreRepository.Save(restore); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,99 @@
package restores
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RestoreController struct {
restoreService *RestoreService
userService *users.UserService
}
func (c *RestoreController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/restores/:backupId", c.GetRestores)
router.POST("/restores/:backupId/restore", c.RestoreBackup)
}
// GetRestores
// @Summary Get restores for a backup
// @Description Get all restores for a specific backup
// @Tags restores
// @Produce json
// @Param backupId path string true "Backup ID"
// @Success 200 {array} models.Restore
// @Failure 400
// @Failure 401
// @Router /restores/{backupId} [get]
func (c *RestoreController) GetRestores(ctx *gin.Context) {
backupID, err := uuid.Parse(ctx.Param("backupId"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
restores, err := c.restoreService.GetRestores(user, backupID)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, restores)
}
// RestoreBackup
// @Summary Restore a backup
// @Description Start a restore process for a specific backup
// @Tags restores
// @Param backupId path string true "Backup ID"
// @Success 200 {object} map[string]string
// @Failure 400
// @Failure 401
// @Router /restores/{backupId}/restore [post]
func (c *RestoreController) RestoreBackup(ctx *gin.Context) {
backupID, err := uuid.Parse(ctx.Param("backupId"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"})
return
}
var requestDTO RestoreBackupRequest
if err := ctx.ShouldBindJSON(&requestDTO); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if err := c.restoreService.RestoreBackupWithAuth(user, backupID, requestDTO); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "restore started successfully"})
}

View File

@@ -0,0 +1,31 @@
package restores
import (
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/restores/usecases"
"postgresus-backend/internal/features/users"
)
var restoreBackupUsecase = &usecases.RestoreBackupUsecase{}
var restoreRepository = &RestoreRepository{}
var restoreService = &RestoreService{
backups.GetBackupService(),
restoreRepository,
restoreBackupUsecase,
}
var restoreController = &RestoreController{
restoreService,
users.GetUserService(),
}
var restoreBackgroundService = &RestoreBackgroundService{
restoreRepository,
}
func GetRestoreController() *RestoreController {
return restoreController
}
func GetRestoreBackgroundService() *RestoreBackgroundService {
return restoreBackgroundService
}

View File

@@ -0,0 +1,9 @@
package restores
import (
"postgresus-backend/internal/features/databases/databases/postgresql"
)
type RestoreBackupRequest struct {
PostgresqlDatabase *postgresql.PostgresqlDatabase `json:"postgresqlDatabase"`
}

View File

@@ -0,0 +1,9 @@
package enums
type RestoreStatus string
const (
RestoreStatusInProgress RestoreStatus = "IN_PROGRESS"
RestoreStatusCompleted RestoreStatus = "COMPLETED"
RestoreStatusFailed RestoreStatus = "FAILED"
)

View File

@@ -0,0 +1,25 @@
package models
import (
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/enums"
"time"
"github.com/google/uuid"
)
type Restore struct {
ID uuid.UUID `json:"id" gorm:"column:id;type:uuid;primaryKey"`
Status enums.RestoreStatus `json:"status" gorm:"column:status;type:text;not null"`
BackupID uuid.UUID `json:"backupId" gorm:"column:backup_id;type:uuid;not null"`
Backup *backups.Backup
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:RestoreID"`
FailMessage *string `json:"failMessage" gorm:"column:fail_message"`
RestoreDurationMs int64 `json:"restoreDurationMs" gorm:"column:restore_duration_ms;default:0"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;default:now()"`
}

View File

@@ -0,0 +1,80 @@
package restores
import (
"postgresus-backend/internal/features/restores/enums"
"postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
)
type RestoreRepository struct{}
func (r *RestoreRepository) Save(restore *models.Restore) error {
db := storage.GetDb()
isNew := restore.ID == uuid.Nil
if isNew {
restore.ID = uuid.New()
return db.Create(restore).
Omit("Backup").
Error
}
return db.Save(restore).
Omit("Backup").
Error
}
func (r *RestoreRepository) FindByBackupID(backupID uuid.UUID) ([]*models.Restore, error) {
var restores []*models.Restore
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("backup_id = ?", backupID).
Order("created_at DESC").
Find(&restores).Error; err != nil {
return nil, err
}
return restores, nil
}
func (r *RestoreRepository) FindByID(id uuid.UUID) (*models.Restore, error) {
var restore models.Restore
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("id = ?", id).
First(&restore).Error; err != nil {
return nil, err
}
return &restore, nil
}
func (r *RestoreRepository) FindByStatus(status enums.RestoreStatus) ([]*models.Restore, error) {
var restores []*models.Restore
if err := storage.
GetDb().
Preload("Backup.Storage").
Preload("Backup.Database").
Preload("Backup").
Preload("Postgresql").
Where("status = ?", status).
Order("created_at DESC").
Find(&restores).Error; err != nil {
return nil, err
}
return restores, nil
}
func (r *RestoreRepository) DeleteByID(id uuid.UUID) error {
return storage.GetDb().Delete(&models.Restore{}, "id = ?", id).Error
}

View File

@@ -0,0 +1,132 @@
package restores
import (
"errors"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/restores/enums"
"postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/features/restores/usecases"
users_models "postgresus-backend/internal/features/users/models"
"time"
"github.com/google/uuid"
)
type RestoreService struct {
backupService *backups.BackupService
restoreRepository *RestoreRepository
restoreBackupUsecase *usecases.RestoreBackupUsecase
}
func (s *RestoreService) GetRestores(
user *users_models.User,
backupID uuid.UUID,
) ([]*models.Restore, error) {
backup, err := s.backupService.GetBackup(backupID)
if err != nil {
return nil, err
}
if backup.Database.UserID != user.ID {
return nil, errors.New("user does not have access to this backup")
}
return s.restoreRepository.FindByBackupID(backupID)
}
func (s *RestoreService) RestoreBackupWithAuth(
user *users_models.User,
backupID uuid.UUID,
requestDTO RestoreBackupRequest,
) error {
backup, err := s.backupService.GetBackup(backupID)
if err != nil {
return err
}
if backup.Database.UserID != user.ID {
return errors.New("user does not have access to this backup")
}
go func() {
if err := s.RestoreBackup(backup, requestDTO); err != nil {
log.Error("Failed to restore backup", "error", err)
}
}()
return nil
}
func (s *RestoreService) RestoreBackup(
backup *backups.Backup,
requestDTO RestoreBackupRequest,
) error {
if backup.Status != backups.BackupStatusCompleted {
return errors.New("backup is not completed")
}
if backup.Database.Type == databases.DatabaseTypePostgres {
if requestDTO.PostgresqlDatabase == nil {
return errors.New("postgresql database is required")
}
}
restore := models.Restore{
ID: uuid.New(),
Status: enums.RestoreStatusInProgress,
BackupID: backup.ID,
Backup: backup,
CreatedAt: time.Now().UTC(),
RestoreDurationMs: 0,
FailMessage: nil,
}
// Save the restore first
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
// Set the RestoreID on the PostgreSQL database and save it
if requestDTO.PostgresqlDatabase != nil {
requestDTO.PostgresqlDatabase.RestoreID = &restore.ID
restore.Postgresql = requestDTO.PostgresqlDatabase
// Save the restore again to include the postgresql database
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
}
start := time.Now().UTC()
err := s.restoreBackupUsecase.Execute(
restore,
backup,
)
if err != nil {
errMsg := err.Error()
restore.FailMessage = &errMsg
restore.Status = enums.RestoreStatusFailed
restore.RestoreDurationMs = time.Since(start).Milliseconds()
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
return err
}
restore.Status = enums.RestoreStatusCompleted
restore.RestoreDurationMs = time.Since(start).Milliseconds()
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,500 @@
package usecases_postgresql
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/util/logger"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
)
var log = logger.GetLogger()
type RestorePostgresqlBackupUsecase struct{}
func (uc *RestorePostgresqlBackupUsecase) Execute(
restore models.Restore,
backup *backups.Backup,
) error {
if restore.Backup.Database.Type != databases.DatabaseTypePostgres {
return errors.New("database type not supported")
}
log.Info(
"Restoring PostgreSQL backup via pg_restore",
"restoreId",
restore.ID,
"backupId",
backup.ID,
)
pg := restore.Postgresql
if pg == nil {
return fmt.Errorf("postgresql configuration is required for restore")
}
if pg.Database == nil || *pg.Database == "" {
return fmt.Errorf("target database name is required for pg_restore")
}
// Use parallel jobs based on CPU count (same as backup)
// Cap between 1 and 8 to avoid overwhelming the server
parallelJobs := max(1, min(pg.CpuCount, 8))
args := []string{
"-Fc", // expect custom format (same as backup)
"-j", strconv.Itoa(parallelJobs), // parallel jobs based on CPU count
"--no-password", // Use environment variable for password, prevent prompts
"-h", pg.Host,
"-p", strconv.Itoa(pg.Port),
"-U", pg.Username,
"-d", *pg.Database,
"--verbose", // Add verbose output to help with debugging
"--clean", // Clean (drop) database objects before recreating them
"--if-exists", // Use IF EXISTS when dropping objects
"--no-owner",
}
return uc.restoreFromStorage(
tools.GetPostgresqlExecutable(
pg.Version,
"pg_restore",
config.GetEnv().EnvMode,
config.GetEnv().PostgresesInstallDir,
),
args,
pg.Password,
backup,
pg,
)
}
// restoreFromStorage restores backup data from storage using pg_restore
func (uc *RestorePostgresqlBackupUsecase) restoreFromStorage(
pgBin string,
args []string,
password string,
backup *backups.Backup,
pgConfig *pgtypes.PostgresqlDatabase,
) error {
log.Info(
"Restoring PostgreSQL backup from storage via temporary file",
"pgBin",
pgBin,
"args",
args,
)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
// Monitor for shutdown and cancel context if needed
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if config.IsShouldShutdown() {
cancel()
return
}
}
}
}()
// Create temporary .pgpass file for authentication
pgpassFile, err := uc.createTempPgpassFile(pgConfig, password)
if err != nil {
return fmt.Errorf("failed to create temporary .pgpass file: %w", err)
}
defer func() {
if pgpassFile != "" {
_ = os.Remove(pgpassFile)
}
}()
// Verify .pgpass file was created successfully
if pgpassFile == "" {
return fmt.Errorf("temporary .pgpass file was not created")
}
if info, err := os.Stat(pgpassFile); err == nil {
log.Info("Temporary .pgpass file created successfully",
"pgpassFile", pgpassFile,
"size", info.Size(),
"mode", info.Mode(),
)
} else {
return fmt.Errorf("failed to verify .pgpass file: %w", err)
}
// Download backup to temporary file
tempBackupFile, cleanupFunc, err := uc.downloadBackupToTempFile(ctx, backup)
if err != nil {
return fmt.Errorf("failed to download backup to temporary file: %w", err)
}
defer cleanupFunc()
// Add the temporary backup file as the last argument to pg_restore
args = append(args, tempBackupFile)
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig)
}
// downloadBackupToTempFile downloads backup data from storage to a temporary file
func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
ctx context.Context,
backup *backups.Backup,
) (string, func(), error) {
// Create temporary directory for backup data
tempDir, err := os.MkdirTemp(config.GetEnv().TempFolder, "restore_"+uuid.New().String())
if err != nil {
return "", nil, fmt.Errorf("failed to create temporary directory: %w", err)
}
cleanupFunc := func() {
_ = os.RemoveAll(tempDir)
}
tempBackupFile := filepath.Join(tempDir, "backup.dump")
// Get backup data from storage
log.Info(
"Downloading backup file from storage to temporary file",
"backupId",
backup.ID,
"tempFile",
tempBackupFile,
)
backupReader, err := backup.Storage.GetFile(backup.ID)
if err != nil {
cleanupFunc()
return "", nil, fmt.Errorf("failed to get backup file from storage: %w", err)
}
defer func() {
if err := backupReader.Close(); err != nil {
log.Error("Failed to close backup reader", "error", err)
}
}()
// Create temporary backup file
tempFile, err := os.Create(tempBackupFile)
if err != nil {
cleanupFunc()
return "", nil, fmt.Errorf("failed to create temporary backup file: %w", err)
}
defer func() {
if err := tempFile.Close(); err != nil {
log.Error("Failed to close temporary file", "error", err)
}
}()
// Copy backup data to temporary file with shutdown checks
_, err = uc.copyWithShutdownCheck(ctx, tempFile, backupReader)
if err != nil {
cleanupFunc()
return "", nil, fmt.Errorf("failed to write backup to temporary file: %w", err)
}
// Close the temp file to ensure all data is written
if err := tempFile.Close(); err != nil {
cleanupFunc()
return "", nil, fmt.Errorf("failed to close temporary backup file: %w", err)
}
log.Info("Backup file written to temporary location", "tempFile", tempBackupFile)
return tempBackupFile, cleanupFunc, nil
}
// executePgRestore executes the pg_restore command with proper environment setup
func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
ctx context.Context,
pgBin string,
args []string,
pgpassFile string,
pgConfig *pgtypes.PostgresqlDatabase,
) error {
cmd := exec.CommandContext(ctx, pgBin, args...)
log.Info("Executing PostgreSQL restore command", "command", cmd.String())
// Setup environment variables
uc.setupPgRestoreEnvironment(cmd, pgpassFile, pgConfig)
// Verify executable exists and is accessible
if _, err := exec.LookPath(pgBin); err != nil {
return fmt.Errorf(
"PostgreSQL executable not found or not accessible: %s - %w",
pgBin,
err,
)
}
// Get stderr to capture any error output
pgStderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
// Capture stderr in a separate goroutine
stderrCh := make(chan []byte, 1)
go func() {
stderrOutput, _ := io.ReadAll(pgStderr)
stderrCh <- stderrOutput
}()
// Start pg_restore
if err = cmd.Start(); err != nil {
return fmt.Errorf("start %s: %w", filepath.Base(pgBin), err)
}
// Wait for the restore to finish
waitErr := cmd.Wait()
stderrOutput := <-stderrCh
// Check for shutdown before finalizing
if config.IsShouldShutdown() {
return fmt.Errorf("restore cancelled due to shutdown")
}
if waitErr != nil {
if config.IsShouldShutdown() {
return fmt.Errorf("restore cancelled due to shutdown")
}
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args)
}
return nil
}
// setupPgRestoreEnvironment configures environment variables for pg_restore
func (uc *RestorePostgresqlBackupUsecase) setupPgRestoreEnvironment(
cmd *exec.Cmd,
pgpassFile string,
pgConfig *pgtypes.PostgresqlDatabase,
) {
// Start with system environment variables
cmd.Env = os.Environ()
// Use the .pgpass file for authentication
cmd.Env = append(cmd.Env, "PGPASSFILE="+pgpassFile)
log.Info("Using temporary .pgpass file for authentication", "pgpassFile", pgpassFile)
// Add PostgreSQL-specific environment variables
cmd.Env = append(cmd.Env, "PGCLIENTENCODING=UTF8")
cmd.Env = append(cmd.Env, "PGCONNECT_TIMEOUT=30")
// Add encoding-related environment variables
cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8")
cmd.Env = append(cmd.Env, "LANG=C.UTF-8")
cmd.Env = append(cmd.Env, "PGOPTIONS=--client-encoding=UTF8")
shouldRequireSSL := pgConfig.IsHttps
// Configure SSL settings
if shouldRequireSSL {
cmd.Env = append(cmd.Env, "PGSSLMODE=require")
log.Info("Using required SSL mode", "configuredHttps", pgConfig.IsHttps)
} else {
cmd.Env = append(cmd.Env, "PGSSLMODE=prefer")
log.Info("Using preferred SSL mode", "configuredHttps", pgConfig.IsHttps)
}
// Set other SSL parameters to avoid certificate issues
cmd.Env = append(cmd.Env, "PGSSLCERT=")
cmd.Env = append(cmd.Env, "PGSSLKEY=")
cmd.Env = append(cmd.Env, "PGSSLROOTCERT=")
cmd.Env = append(cmd.Env, "PGSSLCRL=")
}
// handlePgRestoreError processes and formats pg_restore errors
func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
waitErr error,
stderrOutput []byte,
pgBin string,
args []string,
) error {
// Enhanced error handling for PostgreSQL connection and restore issues
stderrStr := string(stderrOutput)
errorMsg := fmt.Sprintf(
"%s failed: %v stderr: %s",
filepath.Base(pgBin),
waitErr,
stderrStr,
)
// Check for specific PostgreSQL error patterns
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode := exitErr.ExitCode()
if exitCode == 1 && strings.TrimSpace(stderrStr) == "" {
errorMsg = fmt.Sprintf(
"%s failed with exit status 1 but provided no error details. "+
"This often indicates: "+
"1) Connection timeout or refused connection, "+
"2) Authentication failure with incorrect credentials, "+
"3) Database does not exist, "+
"4) Network connectivity issues, "+
"5) PostgreSQL server not running, "+
"6) Backup file is corrupted or incompatible. "+
"Command executed: %s %s",
filepath.Base(pgBin),
pgBin,
strings.Join(args, " "),
)
} else if exitCode == -1073741819 { // 0xC0000005 in decimal
errorMsg = fmt.Sprintf(
"%s crashed with access violation (0xC0000005). This may indicate incompatible PostgreSQL version, corrupted installation, or connection issues. stderr: %s",
filepath.Base(pgBin),
stderrStr,
)
} else if exitCode == 1 || exitCode == 2 {
// Check for common connection and authentication issues
if containsIgnoreCase(stderrStr, "pg_hba.conf") {
errorMsg = fmt.Sprintf(
"PostgreSQL connection rejected by server configuration (pg_hba.conf). stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "no password supplied") || containsIgnoreCase(stderrStr, "fe_sendauth") {
errorMsg = fmt.Sprintf(
"PostgreSQL authentication failed - no password supplied. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "ssl") && containsIgnoreCase(stderrStr, "connection") {
errorMsg = fmt.Sprintf(
"PostgreSQL SSL connection failed. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "connection") && containsIgnoreCase(stderrStr, "refused") {
errorMsg = fmt.Sprintf(
"PostgreSQL connection refused. Check if the server is running and accessible. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "authentication") || containsIgnoreCase(stderrStr, "password") {
errorMsg = fmt.Sprintf(
"PostgreSQL authentication failed. Check username and password. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "timeout") {
errorMsg = fmt.Sprintf(
"PostgreSQL connection timeout. stderr: %s",
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "database") && containsIgnoreCase(stderrStr, "does not exist") {
errorMsg = fmt.Sprintf(
"Target database does not exist. Create the database before restoring. stderr: %s",
stderrStr,
)
}
}
}
return errors.New(errorMsg)
}
// copyWithShutdownCheck copies data from src to dst while checking for shutdown
func (uc *RestorePostgresqlBackupUsecase) copyWithShutdownCheck(
ctx context.Context,
dst io.Writer,
src io.Reader,
) (int64, error) {
buf := make([]byte, 32*1024) // 32KB buffer
var totalBytesWritten int64
for {
select {
case <-ctx.Done():
return totalBytesWritten, fmt.Errorf("copy cancelled: %w", ctx.Err())
default:
}
if config.IsShouldShutdown() {
return totalBytesWritten, fmt.Errorf("copy cancelled due to shutdown")
}
bytesRead, readErr := src.Read(buf)
if bytesRead > 0 {
bytesWritten, writeErr := dst.Write(buf[0:bytesRead])
if bytesWritten < 0 || bytesRead < bytesWritten {
bytesWritten = 0
if writeErr == nil {
writeErr = fmt.Errorf("invalid write result")
}
}
if writeErr != nil {
return totalBytesWritten, writeErr
}
if bytesRead != bytesWritten {
return totalBytesWritten, io.ErrShortWrite
}
totalBytesWritten += int64(bytesWritten)
}
if readErr != nil {
if readErr != io.EOF {
return totalBytesWritten, readErr
}
break
}
}
return totalBytesWritten, nil
}
// containsIgnoreCase checks if a string contains a substring, ignoring case
func containsIgnoreCase(str, substr string) bool {
return strings.Contains(strings.ToLower(str), strings.ToLower(substr))
}
// createTempPgpassFile creates a temporary .pgpass file with the given password
func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
pgConfig *pgtypes.PostgresqlDatabase,
password string,
) (string, error) {
if pgConfig == nil || password == "" {
return "", nil
}
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
pgConfig.Host,
pgConfig.Port,
pgConfig.Username,
password,
)
tempDir, err := os.MkdirTemp("", "pgpass")
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}
pgpassFile := filepath.Join(tempDir, ".pgpass")
err = os.WriteFile(pgpassFile, []byte(pgpassContent), 0600)
if err != nil {
return "", fmt.Errorf("failed to write temporary .pgpass file: %w", err)
}
return pgpassFile, nil
}

View File

@@ -0,0 +1,24 @@
package usecases
import (
"errors"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/restores/models"
usecases_postgresql "postgresus-backend/internal/features/restores/usecases/postgresql"
)
type RestoreBackupUsecase struct {
RestorePostgresqlBackupUsecase *usecases_postgresql.RestorePostgresqlBackupUsecase
}
func (uc *RestoreBackupUsecase) Execute(
restore models.Restore,
backup *backups.Backup,
) error {
if restore.Backup.Database.Type == databases.DatabaseTypePostgres {
return uc.RestorePostgresqlBackupUsecase.Execute(restore, backup)
}
return errors.New("database type not supported")
}

View File

@@ -0,0 +1,224 @@
package storages
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type StorageController struct {
storageService *StorageService
userService *users.UserService
}
func (c *StorageController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/storages", c.SaveStorage)
router.GET("/storages", c.GetStorages)
router.GET("/storages/:id", c.GetStorage)
router.DELETE("/storages/:id", c.DeleteStorage)
router.POST("/storages/:id/test", c.TestStorageConnection)
router.POST("/storages/direct-test", c.TestStorageConnectionDirect)
}
// SaveStorage
// @Summary Save a storage
// @Description Create or update a storage
// @Tags storages
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param storage body Storage true "Storage data"
// @Success 200 {object} Storage
// @Failure 400
// @Failure 401
// @Router /storages [post]
func (c *StorageController) SaveStorage(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var storage Storage
if err := ctx.ShouldBindJSON(&storage); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := storage.Validate(); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := c.storageService.SaveStorage(user, &storage); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, storage)
}
// GetStorage
// @Summary Get a storage by ID
// @Description Get a specific storage by ID
// @Tags storages
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Storage ID"
// @Success 200 {object} Storage
// @Failure 400
// @Failure 401
// @Router /storages/{id} [get]
func (c *StorageController) GetStorage(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
return
}
storage, err := c.storageService.GetStorage(user, id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, storage)
}
// GetStorages
// @Summary Get all storages
// @Description Get all storages for the current user
// @Tags storages
// @Produce json
// @Param Authorization header string true "JWT token"
// @Success 200 {array} Storage
// @Failure 401
// @Router /storages [get]
func (c *StorageController) GetStorages(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
storages, err := c.storageService.GetStorages(user)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, storages)
}
// DeleteStorage
// @Summary Delete a storage
// @Description Delete a storage by ID
// @Tags storages
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Storage ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /storages/{id} [delete]
func (c *StorageController) DeleteStorage(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
return
}
if err := c.storageService.DeleteStorage(user, id); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "storage deleted successfully"})
}
// TestStorageConnection
// @Summary Test storage connection
// @Description Test the connection to the storage
// @Tags storages
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param id path string true "Storage ID"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /storages/{id}/test [post]
func (c *StorageController) TestStorageConnection(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
return
}
if err := c.storageService.TestStorageConnection(user, id); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "storage connection test successful"})
}
// TestStorageConnectionDirect
// @Summary Test storage connection directly
// @Description Test the connection to a storage object provided in the request
// @Tags storages
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param storage body Storage true "Storage data"
// @Success 200
// @Failure 400
// @Failure 401
// @Router /storages/direct-test [post]
func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var storage Storage
if err := ctx.ShouldBindJSON(&storage); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// For direct test, associate with the current user
storage.UserID = user.ID
if err := storage.Validate(); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := c.storageService.TestStorageConnectionDirect(&storage); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "storage connection test successful"})
}

View File

@@ -0,0 +1,22 @@
package storages
import (
"postgresus-backend/internal/features/users"
)
var storageRepository = &StorageRepository{}
var storageService = &StorageService{
storageRepository,
}
var storageController = &StorageController{
storageService,
users.GetUserService(),
}
func GetStorageService() *StorageService {
return storageService
}
func GetStorageController() *StorageController {
return storageController
}

View File

@@ -0,0 +1,8 @@
package storages
type StorageType string
const (
StorageTypeLocal StorageType = "LOCAL"
StorageTypeS3 StorageType = "S3"
)

View File

@@ -0,0 +1,19 @@
package storages
import (
"io"
"github.com/google/uuid"
)
type StorageFileSaver interface {
SaveFile(fileID uuid.UUID, file io.Reader) error
GetFile(fileID uuid.UUID) (io.ReadCloser, error)
DeleteFile(fileID uuid.UUID) error
Validate() error
TestConnection() error
}

View File

@@ -0,0 +1,70 @@
package storages
import (
"errors"
"io"
local_storage "postgresus-backend/internal/features/storages/storages/local"
s3_storage "postgresus-backend/internal/features/storages/storages/s3"
"github.com/google/uuid"
)
type Storage struct {
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null;type:uuid;index"`
Type StorageType `json:"type" gorm:"column:type;not null;type:text"`
Name string `json:"name" gorm:"column:name;not null;type:text"`
LastSaveError *string `json:"lastSaveError" gorm:"column:last_save_error;type:text"`
// specific storage
LocalStorage *local_storage.LocalStorage `json:"localStorage" gorm:"foreignKey:StorageID"`
S3Storage *s3_storage.S3Storage `json:"s3Storage" gorm:"foreignKey:StorageID"`
}
func (s *Storage) SaveFile(fileID uuid.UUID, file io.Reader) error {
err := s.getSpecificStorage().SaveFile(fileID, file)
if err != nil {
lastSaveError := err.Error()
s.LastSaveError = &lastSaveError
return err
}
s.LastSaveError = nil
return nil
}
func (s *Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
return s.getSpecificStorage().GetFile(fileID)
}
func (s *Storage) DeleteFile(fileID uuid.UUID) error {
return s.getSpecificStorage().DeleteFile(fileID)
}
func (s *Storage) Validate() error {
if s.Type == "" {
return errors.New("storage type is required")
}
if s.Name == "" {
return errors.New("storage name is required")
}
return s.getSpecificStorage().Validate()
}
func (s *Storage) TestConnection() error {
return s.getSpecificStorage().TestConnection()
}
func (s *Storage) getSpecificStorage() StorageFileSaver {
switch s.Type {
case StorageTypeLocal:
return s.LocalStorage
case StorageTypeS3:
return s.S3Storage
default:
panic("invalid storage type: " + string(s.Type))
}
}

View File

@@ -0,0 +1,113 @@
package storages
import (
db "postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type StorageRepository struct{}
func (r *StorageRepository) Save(s *Storage) error {
database := db.GetDb()
return database.Transaction(func(tx *gorm.DB) error {
switch s.Type {
case StorageTypeLocal:
if s.LocalStorage != nil {
s.LocalStorage.StorageID = s.ID
}
case StorageTypeS3:
if s.S3Storage != nil {
s.S3Storage.StorageID = s.ID
}
}
if s.ID == uuid.Nil {
if err := tx.Create(s).
Omit("LocalStorage", "S3Storage").
Error; err != nil {
return err
}
} else {
if err := tx.Save(s).
Omit("LocalStorage", "S3Storage").
Error; err != nil {
return err
}
}
switch s.Type {
case StorageTypeLocal:
if s.LocalStorage != nil {
s.LocalStorage.StorageID = s.ID // Ensure ID is set
if err := tx.Save(s.LocalStorage).Error; err != nil {
return err
}
}
case StorageTypeS3:
if s.S3Storage != nil {
s.S3Storage.StorageID = s.ID // Ensure ID is set
if err := tx.Save(s.S3Storage).Error; err != nil {
return err
}
}
}
return nil
})
}
func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) {
var s Storage
if err := db.
GetDb().
Preload("LocalStorage").
Preload("S3Storage").
Where("id = ?", id).
First(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
func (r *StorageRepository) FindByUserID(userID uuid.UUID) ([]*Storage, error) {
var storages []*Storage
if err := db.
GetDb().
Preload("LocalStorage").
Preload("S3Storage").
Where("user_id = ?", userID).
Find(&storages).Error; err != nil {
return nil, err
}
return storages, nil
}
func (r *StorageRepository) Delete(s *Storage) error {
return db.GetDb().Transaction(func(tx *gorm.DB) error {
// Delete specific storage based on type
switch s.Type {
case StorageTypeLocal:
if s.LocalStorage != nil {
if err := tx.Delete(s.LocalStorage).Error; err != nil {
return err
}
}
case StorageTypeS3:
if s.S3Storage != nil {
if err := tx.Delete(s.S3Storage).Error; err != nil {
return err
}
}
}
// Delete the main storage
return tx.Delete(s).Error
})
}

View File

@@ -0,0 +1,108 @@
package storages
import (
"errors"
users_models "postgresus-backend/internal/features/users/models"
"github.com/google/uuid"
)
type StorageService struct {
storageRepository *StorageRepository
}
func (s *StorageService) SaveStorage(
user *users_models.User,
storage *Storage,
) error {
if storage.ID != uuid.Nil {
existingStorage, err := s.storageRepository.FindByID(storage.ID)
if err != nil {
return err
}
if existingStorage.UserID != user.ID {
return errors.New("you have not access to this storage")
}
storage.UserID = existingStorage.UserID
} else {
storage.UserID = user.ID
}
return s.storageRepository.Save(storage)
}
func (s *StorageService) DeleteStorage(
user *users_models.User,
storageID uuid.UUID,
) error {
storage, err := s.storageRepository.FindByID(storageID)
if err != nil {
return err
}
if storage.UserID != user.ID {
return errors.New("you have not access to this storage")
}
return s.storageRepository.Delete(storage)
}
func (s *StorageService) GetStorage(
user *users_models.User,
id uuid.UUID,
) (*Storage, error) {
storage, err := s.storageRepository.FindByID(id)
if err != nil {
return nil, err
}
if storage.UserID != user.ID {
return nil, errors.New("you have not access to this storage")
}
return storage, nil
}
func (s *StorageService) GetStorages(
user *users_models.User,
) ([]*Storage, error) {
return s.storageRepository.FindByUserID(user.ID)
}
func (s *StorageService) TestStorageConnection(
user *users_models.User,
storageID uuid.UUID,
) error {
storage, err := s.storageRepository.FindByID(storageID)
if err != nil {
return err
}
if storage.UserID != user.ID {
return errors.New("you have not access to this storage")
}
err = storage.TestConnection()
if err != nil {
lastSaveError := err.Error()
storage.LastSaveError = &lastSaveError
return err
}
storage.LastSaveError = nil
return s.storageRepository.Save(storage)
}
func (s *StorageService) TestStorageConnectionDirect(
storage *Storage,
) error {
return storage.TestConnection()
}
func (s *StorageService) GetStorageByID(
id uuid.UUID,
) (*Storage, error) {
return s.storageRepository.FindByID(id)
}

View File

@@ -0,0 +1,176 @@
package local_storage
import (
"fmt"
"io"
"os"
"path/filepath"
"postgresus-backend/internal/config"
"postgresus-backend/internal/util/logger"
"github.com/google/uuid"
)
var log = logger.GetLogger()
// LocalStorage uses ./postgresus_local_backups folder as a
// directory for backups and ./postgresus_local_temp folder as a
// directory for temp files
type LocalStorage struct {
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
}
func (l *LocalStorage) TableName() string {
return "local_storages"
}
func (l *LocalStorage) SaveFile(fileID uuid.UUID, file io.Reader) error {
log.Info("Starting to save file to local storage", "fileId", fileID.String())
if err := l.ensureDirectories(); err != nil {
log.Error("Failed to ensure directories", "fileId", fileID.String(), "error", err)
return err
}
tempFilePath := filepath.Join(config.GetEnv().TempFolder, fileID.String())
log.Debug("Creating temp file", "fileId", fileID.String(), "tempPath", tempFilePath)
tempFile, err := os.Create(tempFilePath)
if err != nil {
log.Error(
"Failed to create temp file",
"fileId",
fileID.String(),
"tempPath",
tempFilePath,
"error",
err,
)
return fmt.Errorf("failed to create temp file: %w", err)
}
defer func() {
_ = tempFile.Close()
}()
log.Debug("Copying file data to temp file", "fileId", fileID.String())
_, err = io.Copy(tempFile, file)
if err != nil {
log.Error("Failed to write to temp file", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to write to temp file: %w", err)
}
if err = tempFile.Sync(); err != nil {
log.Error("Failed to sync temp file", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to sync temp file: %w", err)
}
err = tempFile.Close()
if err != nil {
log.Error("Failed to close temp file", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to close temp file: %w", err)
}
finalPath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
log.Debug(
"Moving file from temp to final location",
"fileId",
fileID.String(),
"finalPath",
finalPath,
)
// Move the file from temp to backups directory
if err = os.Rename(tempFilePath, finalPath); err != nil {
log.Error(
"Failed to move file from temp to backups",
"fileId",
fileID.String(),
"tempPath",
tempFilePath,
"finalPath",
finalPath,
"error",
err,
)
return fmt.Errorf("failed to move file from temp to backups: %w", err)
}
log.Info(
"Successfully saved file to local storage",
"fileId",
fileID.String(),
"finalPath",
finalPath,
)
return nil
}
func (l *LocalStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", fileID.String())
}
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
return file, nil
}
func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error {
filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil
}
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
func (l *LocalStorage) Validate() error {
return l.ensureDirectories()
}
func (l *LocalStorage) TestConnection() error {
if err := l.ensureDirectories(); err != nil {
return err
}
testFile := filepath.Join(config.GetEnv().TempFolder, "test_connection")
f, err := os.Create(testFile)
if err != nil {
return fmt.Errorf("failed to create test file: %w", err)
}
if err = f.Close(); err != nil {
return fmt.Errorf("failed to close test file: %w", err)
}
if err = os.Remove(testFile); err != nil {
return fmt.Errorf("failed to remove test file: %w", err)
}
return nil
}
func (l *LocalStorage) ensureDirectories() error {
// Standard permissions for directories: owner
// can read/write/execute, others can read/execute
const directoryPermissions = 0755
if err := os.MkdirAll(config.GetEnv().DataFolder, directoryPermissions); err != nil {
return fmt.Errorf("failed to create backups directory: %w", err)
}
if err := os.MkdirAll(config.GetEnv().TempFolder, directoryPermissions); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
return nil
}

View File

@@ -0,0 +1,175 @@
package s3_storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type S3Storage struct {
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
S3Bucket string `json:"s3Bucket" gorm:"not null;type:text;column:s3_bucket"`
S3Region string `json:"s3Region" gorm:"not null;type:text;column:s3_region"`
S3AccessKey string `json:"s3AccessKey" gorm:"not null;type:text;column:s3_access_key"`
S3SecretKey string `json:"s3SecretKey" gorm:"not null;type:text;column:s3_secret_key"`
S3Endpoint string `json:"s3Endpoint" gorm:"type:text;column:s3_endpoint"`
}
func (s *S3Storage) TableName() string {
return "s3_storages"
}
func (s *S3Storage) SaveFile(fileID uuid.UUID, file io.Reader) error {
client, err := s.getClient()
if err != nil {
return err
}
// Read the entire file into memory
data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Upload the file using MinIO client
_, err = client.PutObject(
context.TODO(),
s.S3Bucket,
fileID.String()+".zip",
bytes.NewReader(data),
int64(len(data)),
minio.PutObjectOptions{},
)
if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err)
}
return nil
}
func (s *S3Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
client, err := s.getClient()
if err != nil {
return nil, err
}
// Get the object using MinIO client
object, err := client.GetObject(
context.TODO(),
s.S3Bucket,
fileID.String()+".zip",
minio.GetObjectOptions{},
)
if err != nil {
return nil, fmt.Errorf("failed to get file from S3: %w", err)
}
return object, nil
}
func (s *S3Storage) DeleteFile(fileID uuid.UUID) error {
client, err := s.getClient()
if err != nil {
return err
}
// Delete the object using MinIO client
err = client.RemoveObject(
context.TODO(),
s.S3Bucket,
fileID.String()+".zip",
minio.RemoveObjectOptions{},
)
if err != nil {
return fmt.Errorf("failed to delete file from S3: %w", err)
}
return nil
}
func (s *S3Storage) Validate() error {
if s.S3Bucket == "" {
return errors.New("S3 bucket is required")
}
if s.S3Region == "" {
return errors.New("S3 region is required")
}
if s.S3AccessKey == "" {
return errors.New("S3 access key is required")
}
if s.S3SecretKey == "" {
return errors.New("S3 secret key is required")
}
// Try to create a client to validate the configuration
_, err := s.getClient()
if err != nil {
return fmt.Errorf("invalid S3 configuration: %w", err)
}
return nil
}
func (s *S3Storage) TestConnection() error {
client, err := s.getClient()
if err != nil {
return err
}
// Create a context with 5 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Check if the bucket exists to verify connection
exists, err := client.BucketExists(ctx, s.S3Bucket)
if err != nil {
// Check if the error is due to context deadline exceeded
if errors.Is(err, context.DeadlineExceeded) {
return errors.New("failed to connect to the bucket. Please check params")
}
return fmt.Errorf("failed to connect to S3: %w", err)
}
if !exists {
return fmt.Errorf("bucket '%s' does not exist", s.S3Bucket)
}
return nil
}
func (s *S3Storage) getClient() (*minio.Client, error) {
endpoint := s.S3Endpoint
useSSL := true
if strings.HasPrefix(endpoint, "http://") {
useSSL = false
endpoint = strings.TrimPrefix(endpoint, "http://")
} else if strings.HasPrefix(endpoint, "https://") {
endpoint = strings.TrimPrefix(endpoint, "https://")
}
// If no endpoint is provided, use the AWS S3 endpoint for the region
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", s.S3Region)
}
// Initialize the MinIO client
minioClient, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(s.S3AccessKey, s.S3SecretKey, ""),
Secure: useSSL,
Region: s.S3Region,
})
if err != nil {
return nil, fmt.Errorf("failed to initialize MinIO client: %w", err)
}
return minioClient, nil
}

View File

@@ -0,0 +1,86 @@
package users
import (
"net/http"
"github.com/gin-gonic/gin"
)
type UserController struct {
userService *UserService
}
func (c *UserController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/users/signup", c.SignUp)
router.POST("/users/signin", c.SignIn)
router.GET("/users/is-any-user-exist", c.IsAnyUserExist)
}
// SignUp
// @Summary Register a new user
// @Description Register a new user with email and password
// @Tags users
// @Accept json
// @Produce json
// @Param request body SignUpRequest true "User signup data"
// @Success 200
// @Failure 400
// @Router /users/signup [post]
func (c *UserController) SignUp(ctx *gin.Context) {
var request SignUpRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
err := c.userService.SignUp(&request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "User created successfully"})
}
// SignIn
// @Summary Authenticate a user
// @Description Authenticate a user with email and password
// @Tags users
// @Accept json
// @Produce json
// @Param request body SignInRequest true "User signin data"
// @Success 200 {object} SignInResponse
// @Failure 400
// @Router /users/signin [post]
func (c *UserController) SignIn(ctx *gin.Context) {
var request SignInRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
response, err := c.userService.SignIn(&request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, response)
}
// IsAnyUserExist
// @Summary Check if any user exists
// @Description Check if any user exists in the system
// @Tags users
// @Produce json
// @Success 200 {object} map[string]bool
// @Router /users/is-any-user-exist [get]
func (c *UserController) IsAnyUserExist(ctx *gin.Context) {
isExist, err := c.userService.IsAnyUserExist()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"isExist": isExist})
}

View File

@@ -0,0 +1,23 @@
package users
import (
user_repositories "postgresus-backend/internal/features/users/repositories"
)
var secretKeyRepository = &user_repositories.SecretKeyRepository{}
var userRepository = &user_repositories.UserRepository{}
var userService = &UserService{
userRepository,
secretKeyRepository,
}
var userController = &UserController{
userService,
}
func GetUserService() *UserService {
return userService
}
func GetUserController() *UserController {
return userController
}

View File

@@ -0,0 +1,18 @@
package users
import "github.com/google/uuid"
type SignUpRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
type SignInRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type SignInResponse struct {
UserID uuid.UUID `json:"userId"`
Token string `json:"token"`
}

View File

@@ -0,0 +1,7 @@
package user_enums
type UserRole string
const (
UserRoleAdmin UserRole = "ADMIN"
)

View File

@@ -0,0 +1,9 @@
package users_models
type SecretKey struct {
Secret string `gorm:"column:secret;uniqueIndex;not null"`
}
func (SecretKey) TableName() string {
return "secret_keys"
}

View File

@@ -0,0 +1,21 @@
package users_models
import (
user_enums "postgresus-backend/internal/features/users/enums"
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
HashedPassword string `json:"-" gorm:"not null"`
PasswordCreationTime time.Time `json:"-" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"not null;default:now()"`
Role user_enums.UserRole `json:"role" gorm:"type:text;not null"`
}
func (User) TableName() string {
return "users"
}

View File

@@ -0,0 +1,36 @@
package user_repositories
import (
"errors"
user_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type SecretKeyRepository struct{}
func (r *SecretKeyRepository) GetSecretKey() (string, error) {
var secretKey user_models.SecretKey
if err := storage.
GetDb().
First(&secretKey).Error; err != nil {
// create a new secret key if not found
if errors.Is(err, gorm.ErrRecordNotFound) {
newSecretKey := user_models.SecretKey{
Secret: uuid.New().String() + uuid.New().String(),
}
if err := storage.GetDb().Create(&newSecretKey).Error; err != nil {
return "", errors.New("failed to create new secret key")
}
return newSecretKey.Secret, nil
}
return "", err
}
return secretKey.Secret, nil
}

View File

@@ -0,0 +1,68 @@
package user_repositories
import (
user_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/storage"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserRepository struct{}
func (r *UserRepository) IsAnyUserExist() (bool, error) {
var user user_models.User
if err := storage.GetDb().First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
return true, nil
}
func (r *UserRepository) CreateUser(user *user_models.User) error {
return storage.GetDb().Create(user).Error
}
func (r *UserRepository) GetUserByEmail(email string) (*user_models.User, error) {
var user user_models.User
if err := storage.GetDb().Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetUserByID(userID string) (*user_models.User, error) {
var user user_models.User
if err := storage.GetDb().Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetFirstUser() (*user_models.User, error) {
var user user_models.User
if err := storage.GetDb().First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) UpdateUserPassword(userID uuid.UUID, hashedPassword string) error {
return storage.GetDb().Model(&user_models.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"hashed_password": hashedPassword,
"password_creation_time": time.Now().UTC(),
}).Error
}

View File

@@ -0,0 +1,153 @@
package users
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
user_enums "postgresus-backend/internal/features/users/enums"
user_models "postgresus-backend/internal/features/users/models"
user_repositories "postgresus-backend/internal/features/users/repositories"
)
type UserService struct {
userRepository *user_repositories.UserRepository
secretKeyRepository *user_repositories.SecretKeyRepository
}
func (s *UserService) IsAnyUserExist() (bool, error) {
return s.userRepository.IsAnyUserExist()
}
func (s *UserService) SignUp(request *SignUpRequest) error {
isAnyUserExists, err := s.userRepository.IsAnyUserExist()
if err != nil {
return fmt.Errorf("failed to check if any user exists: %w", err)
}
if isAnyUserExists {
return errors.New("admin user already registered")
}
existingUser, err := s.userRepository.GetUserByEmail(request.Email)
if err == nil && existingUser != nil {
return errors.New("user with this email already exists")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user := &user_models.User{
ID: uuid.New(),
Email: request.Email,
HashedPassword: string(hashedPassword),
PasswordCreationTime: time.Now().UTC(),
CreatedAt: time.Now().UTC(),
Role: user_enums.UserRoleAdmin,
}
if err := s.userRepository.CreateUser(user); err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
func (s *UserService) SignIn(request *SignInRequest) (*SignInResponse, error) {
user, err := s.userRepository.GetUserByEmail(request.Email)
if err != nil {
return nil, errors.New("user with this email does not exist")
}
err = bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(request.Password))
if err != nil {
return nil, errors.New("password is incorrect")
}
secretKey, err := s.secretKeyRepository.GetSecretKey()
if err != nil {
return nil, fmt.Errorf("failed to get secret key: %w", err)
}
tenYearsExpiration := time.Now().UTC().Add(time.Hour * 24 * 365 * 10)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": user.ID,
"exp": tenYearsExpiration.Unix(),
"iat": time.Now().UTC().Unix(),
"role": string(user.Role),
})
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
return &SignInResponse{
UserID: user.ID,
Token: tokenString,
}, nil
}
func (s *UserService) GetUserFromToken(token string) (*user_models.User, error) {
secretKey, err := s.secretKeyRepository.GetSecretKey()
if err != nil {
return nil, fmt.Errorf("failed to get secret key: %w", err)
}
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secretKey), nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid {
userID, ok := claims["sub"].(string)
if !ok {
return nil, errors.New("invalid token claims")
}
user, err := s.userRepository.GetUserByID(userID)
if err != nil {
return nil, err
}
return user, nil
}
return nil, errors.New("invalid token")
}
func (s *UserService) ChangePassword(newPassword string) error {
exists, err := s.userRepository.IsAnyUserExist()
if err != nil || !exists {
return errors.New("no users exist to change password")
}
user, err := s.userRepository.GetFirstUser()
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
if err := s.userRepository.UpdateUserPassword(user.ID, string(hashedPassword)); err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}

View File

@@ -0,0 +1,55 @@
package storage
import (
"os"
"postgresus-backend/internal/config"
"postgresus-backend/internal/util/logger"
"sync"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
var log = logger.GetLogger()
var (
db *gorm.DB
dbOnce sync.Once
)
func GetDb() *gorm.DB {
dbOnce.Do(loadDbs)
return db
}
func loadDbs() {
LoadMainDb()
}
func LoadMainDb() {
dbDsn := config.GetEnv().DatabaseDsn
log.Info("Connection to database...")
database, err := gorm.Open(postgres.Open(dbDsn), &gorm.Config{
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
})
if err != nil {
log.Error("error on connecting to database", "error", err)
os.Exit(1)
}
sqlDB, err := database.DB()
if err != nil {
log.Error("error getting underlying sql.DB", "error", err)
os.Exit(1)
}
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(20)
db = database
log.Info("Main database connected successfully!")
}

8
backend/internal/util/env/enums.go vendored Normal file
View File

@@ -0,0 +1,8 @@
package env_utils
type EnvMode string
const (
EnvModeDevelopment EnvMode = "development"
EnvModeProduction EnvMode = "production"
)

View File

@@ -0,0 +1,7 @@
package files_utils
import "os"
func CleanFolder(folder string) error {
return os.RemoveAll(folder)
}

View File

@@ -0,0 +1,48 @@
package logger
import (
"log/slog"
"os"
"sync"
"time"
)
var (
loggerInstance *slog.Logger
once sync.Once
)
// GetLogger returns a singleton slog.Logger that logs to the console
func GetLogger() *slog.Logger {
once.Do(func() {
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(time.Now().Format("2006/01/02 15:04:05"))
}
if a.Key == slog.MessageKey {
// Format the message to match the desired output format
return slog.Attr{
Key: slog.MessageKey,
Value: slog.StringValue(a.Value.String()),
}
}
// Remove level and other attributes to get clean output
if a.Key == slog.LevelKey {
return slog.Attr{}
}
return a
},
})
loggerInstance = slog.New(handler)
loggerInstance.Info("Text structured logger initialized")
})
return loggerInstance
}

View File

@@ -0,0 +1,18 @@
package tools
type PostgresqlVersion string
const (
PostgresqlVersion13 PostgresqlVersion = "13"
PostgresqlVersion14 PostgresqlVersion = "14"
PostgresqlVersion15 PostgresqlVersion = "15"
PostgresqlVersion16 PostgresqlVersion = "16"
PostgresqlVersion17 PostgresqlVersion = "17"
)
type PostgresqlExecutable string
const (
PostgresqlExecutablePgDump PostgresqlExecutable = "pg_dump"
PostgresqlExecutablePsql PostgresqlExecutable = "psql"
)

View File

@@ -0,0 +1,164 @@
package tools
import (
"fmt"
"os"
"path/filepath"
"runtime"
env_utils "postgresus-backend/internal/util/env"
"postgresus-backend/internal/util/logger"
)
var log = logger.GetLogger()
// GetPostgresqlExecutable returns the full path to a specific PostgreSQL executable
// for the given version. Common executables include: pg_dump, psql, etc.
// On Windows, automatically appends .exe extension.
func GetPostgresqlExecutable(
version PostgresqlVersion,
executable PostgresqlExecutable,
envMode env_utils.EnvMode,
postgresesInstallDir string,
) string {
basePath := getPostgresqlBasePath(version, envMode, postgresesInstallDir)
executableName := string(executable)
// Add .exe extension on Windows
if runtime.GOOS == "windows" {
executableName += ".exe"
}
return filepath.Join(basePath, executableName)
}
// VerifyPostgresesInstallation verifies that PostgreSQL versions 13-17 are installed
// in the current environment. Each version should be installed with the required
// client tools (pg_dump, psql) available.
// In development: ./tools/postgresql/postgresql-{VERSION}/bin
// In production: /usr/pgsql-{VERSION}/bin
func VerifyPostgresesInstallation(envMode env_utils.EnvMode, postgresesInstallDir string) {
versions := []PostgresqlVersion{
PostgresqlVersion13,
PostgresqlVersion14,
PostgresqlVersion15,
PostgresqlVersion16,
PostgresqlVersion17,
}
requiredCommands := []PostgresqlExecutable{
PostgresqlExecutablePgDump,
PostgresqlExecutablePsql,
}
for _, version := range versions {
binDir := getPostgresqlBasePath(version, envMode, postgresesInstallDir)
log.Info(
"Verifying PostgreSQL installation",
"version",
string(version),
"path",
binDir,
)
if _, err := os.Stat(binDir); os.IsNotExist(err) {
if envMode == env_utils.EnvModeDevelopment {
log.Error(
"PostgreSQL bin directory not found. Make sure PostgreSQL is installed. Read ./tools/readme.md for details",
"version",
string(version),
"path",
binDir,
)
} else {
log.Error(
"PostgreSQL bin directory not found. Please ensure PostgreSQL client tools are installed.",
"version",
string(version),
"path",
binDir,
)
}
os.Exit(1)
}
for _, cmd := range requiredCommands {
cmdPath := GetPostgresqlExecutable(
version,
cmd,
envMode,
postgresesInstallDir,
)
log.Info(
"Checking for PostgreSQL command",
"command",
cmd,
"version",
string(version),
"path",
cmdPath,
)
if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
if envMode == env_utils.EnvModeDevelopment {
log.Error(
"PostgreSQL command not found. Make sure PostgreSQL is installed. Read ./tools/readme.md for details",
"command",
cmd,
"version",
string(version),
"path",
cmdPath,
)
} else {
log.Error(
"PostgreSQL command not found. Please ensure PostgreSQL client tools are properly installed.",
"command",
cmd,
"version",
string(version),
"path",
cmdPath,
)
}
os.Exit(1)
}
log.Info(
"PostgreSQL command found",
"command",
cmd,
"version",
string(version),
)
}
log.Info(
"Installation of PostgreSQL verified",
"version",
string(version),
"path",
binDir,
)
}
log.Info("All PostgreSQL version-specific client tools verification completed successfully!")
}
func getPostgresqlBasePath(
version PostgresqlVersion,
envMode env_utils.EnvMode,
postgresesInstallDir string,
) string {
if envMode == env_utils.EnvModeDevelopment {
return filepath.Join(
postgresesInstallDir,
fmt.Sprintf("postgresql-%s", string(version)),
"bin",
)
} else {
return fmt.Sprintf("/usr/pgsql-%s/bin", string(version))
}
}

View File

@@ -0,0 +1,260 @@
-- +goose Up
-- +goose StatementBegin
-- Create users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
hashed_password TEXT NOT NULL,
password_creation_time TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
role TEXT NOT NULL
);
CREATE UNIQUE INDEX users_email_idx ON users (email);
-- Create secret keys table
CREATE TABLE secret_keys (
secret TEXT NOT NULL
);
CREATE UNIQUE INDEX secret_keys_secret_idx ON secret_keys (secret);
-- Create notifiers table
CREATE TABLE notifiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
notifier_type VARCHAR(50) NOT NULL,
last_send_error TEXT
);
CREATE INDEX idx_notifiers_user_id ON notifiers (user_id);
-- Create telegram notifiers table
CREATE TABLE telegram_notifiers (
notifier_id UUID PRIMARY KEY,
bot_token TEXT NOT NULL,
target_chat_id TEXT NOT NULL
);
ALTER TABLE telegram_notifiers
ADD CONSTRAINT fk_telegram_notifiers_notifier
FOREIGN KEY (notifier_id)
REFERENCES notifiers (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- Create email notifiers table
CREATE TABLE email_notifiers (
notifier_id UUID PRIMARY KEY,
target_email VARCHAR(255) NOT NULL,
smtp_host VARCHAR(255) NOT NULL,
smtp_port INTEGER NOT NULL,
smtp_user VARCHAR(255) NOT NULL,
smtp_password VARCHAR(255) NOT NULL
);
ALTER TABLE email_notifiers
ADD CONSTRAINT fk_email_notifiers_notifier
FOREIGN KEY (notifier_id)
REFERENCES notifiers (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- Create storages table
CREATE TABLE storages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
last_save_error TEXT
);
CREATE INDEX idx_storages_user_id ON storages (user_id);
-- Create local storages table
CREATE TABLE local_storages (
storage_id UUID PRIMARY KEY
);
ALTER TABLE local_storages
ADD CONSTRAINT fk_local_storages_storage
FOREIGN KEY (storage_id)
REFERENCES storages (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- Create S3 storages table
CREATE TABLE s3_storages (
storage_id UUID PRIMARY KEY,
s3_bucket TEXT NOT NULL,
s3_region TEXT NOT NULL,
s3_access_key TEXT NOT NULL,
s3_secret_key TEXT NOT NULL,
s3_endpoint TEXT
);
ALTER TABLE s3_storages
ADD CONSTRAINT fk_s3_storages_storage
FOREIGN KEY (storage_id)
REFERENCES storages (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- Create intervals table
CREATE TABLE intervals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interval TEXT NOT NULL,
time_of_day TEXT,
weekday INT,
day_of_month INT
);
-- Create databases table
CREATE TABLE databases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
backup_interval_id UUID NOT NULL,
storage_id UUID NOT NULL,
store_period TEXT NOT NULL,
last_backup_time TIMESTAMPTZ,
last_backup_error_message TEXT,
send_notifications_on TEXT NOT NULL DEFAULT ''
);
ALTER TABLE databases
ADD CONSTRAINT fk_databases_backup_interval_id
FOREIGN KEY (backup_interval_id)
REFERENCES intervals (id)
ON DELETE RESTRICT;
ALTER TABLE databases
ADD CONSTRAINT fk_databases_user_id
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE;
ALTER TABLE databases
ADD CONSTRAINT fk_databases_storage_id
FOREIGN KEY (storage_id)
REFERENCES storages (id)
ON DELETE RESTRICT;
CREATE INDEX idx_databases_user_id ON databases (user_id);
CREATE INDEX idx_databases_storage_id ON databases (storage_id);
-- Create postgresql databases table
CREATE TABLE postgresql_databases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
database_id UUID,
version TEXT NOT NULL,
host TEXT NOT NULL,
port INT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
database TEXT,
is_https BOOLEAN NOT NULL DEFAULT FALSE,
cpu_count INT NOT NULL,
restore_id UUID
);
ALTER TABLE postgresql_databases
ADD CONSTRAINT uk_postgresql_databases_database_id
UNIQUE (database_id);
CREATE INDEX idx_postgresql_databases_database_id ON postgresql_databases (database_id);
CREATE INDEX idx_postgresql_databases_restore_id ON postgresql_databases (restore_id);
-- Create database notifiers association table
CREATE TABLE database_notifiers (
database_id UUID NOT NULL,
notifier_id UUID NOT NULL,
PRIMARY KEY (database_id, notifier_id)
);
ALTER TABLE database_notifiers
ADD CONSTRAINT fk_database_notifiers_database_id
FOREIGN KEY (database_id)
REFERENCES databases (id)
ON DELETE CASCADE;
ALTER TABLE database_notifiers
ADD CONSTRAINT fk_database_notifiers_notifier_id
FOREIGN KEY (notifier_id)
REFERENCES notifiers (id)
ON DELETE RESTRICT;
CREATE INDEX idx_database_notifiers_database_id ON database_notifiers (database_id);
-- Create backups table
CREATE TABLE backups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
database_id UUID NOT NULL,
storage_id UUID NOT NULL,
status TEXT NOT NULL,
fail_message TEXT,
backup_size_mb DOUBLE PRECISION NOT NULL DEFAULT 0,
backup_duration_ms BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE backups
ADD CONSTRAINT fk_backups_database_id
FOREIGN KEY (database_id)
REFERENCES databases (id)
ON DELETE CASCADE;
ALTER TABLE backups
ADD CONSTRAINT fk_backups_storage_id
FOREIGN KEY (storage_id)
REFERENCES storages (id)
ON DELETE RESTRICT;
CREATE INDEX idx_backups_database_id_created_at ON backups (database_id, created_at DESC);
CREATE INDEX idx_backups_status_created_at ON backups (status, created_at DESC);
-- Create restores table
CREATE TABLE restores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
backup_id UUID NOT NULL,
status TEXT NOT NULL,
fail_message TEXT,
restore_duration_ms BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE restores
ADD CONSTRAINT fk_restores_backup_id
FOREIGN KEY (backup_id)
REFERENCES backups (id)
ON DELETE CASCADE;
ALTER TABLE postgresql_databases
ADD CONSTRAINT fk_postgresql_databases_restore_id
FOREIGN KEY (restore_id)
REFERENCES restores (id)
ON DELETE CASCADE;
CREATE INDEX idx_restores_backup_id_created_at ON restores (backup_id, created_at);
CREATE INDEX idx_restores_status_created_at ON restores (status, created_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS restores;
DROP TABLE IF EXISTS backups;
DROP TABLE IF EXISTS database_notifiers;
DROP TABLE IF EXISTS postgresql_databases;
DROP TABLE IF EXISTS databases;
DROP TABLE IF EXISTS intervals;
DROP TABLE IF EXISTS s3_storages;
DROP TABLE IF EXISTS local_storages;
DROP TABLE IF EXISTS storages;
DROP TABLE IF EXISTS email_notifiers;
DROP TABLE IF EXISTS telegram_notifiers;
DROP TABLE IF EXISTS notifiers;
DROP TABLE IF EXISTS secret_keys;
DROP TABLE IF EXISTS users;
-- +goose StatementEnd

2
backend/tools/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
postgresql
downloads

View File

@@ -0,0 +1,101 @@
#!/bin/bash
set -e # Exit on any error
echo "Installing PostgreSQL client tools versions 13-17 for Linux (Debian/Ubuntu)..."
echo
# Check if running on supported system
if ! command -v apt-get &> /dev/null; then
echo "Error: This script requires apt-get (Debian/Ubuntu-like system)"
exit 1
fi
# Check if running as root or with sudo
if [[ $EUID -eq 0 ]]; then
SUDO=""
else
SUDO="sudo"
echo "This script requires sudo privileges to install packages."
fi
# Create postgresql directory
mkdir -p postgresql
# Get absolute path
POSTGRES_DIR="$(pwd)/postgresql"
echo "Installing PostgreSQL client tools to: $POSTGRES_DIR"
echo
# Add PostgreSQL official APT repository
echo "Adding PostgreSQL official APT repository..."
$SUDO apt-get update -qq
$SUDO apt-get install -y wget ca-certificates
# Add GPG key
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | $SUDO apt-key add -
# Add repository
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list
# Update package list
echo "Updating package list..."
$SUDO apt-get update -qq
# Install client tools for each version
versions="13 14 15 16 17"
for version in $versions; do
echo "Installing PostgreSQL $version client tools..."
# Install client tools only
$SUDO apt-get install -y postgresql-client-$version
# Create version-specific directory and symlinks
version_dir="$POSTGRES_DIR/postgresql-$version"
mkdir -p "$version_dir/bin"
# Create symlinks to the installed binaries
if [ -f "/usr/bin/pg_dump" ]; then
# If multiple versions, binaries are usually named with version suffix
if [ -f "/usr/bin/pg_dump-$version" ]; then
ln -sf "/usr/bin/pg_dump-$version" "$version_dir/bin/pg_dump"
ln -sf "/usr/bin/pg_dumpall-$version" "$version_dir/bin/pg_dumpall"
ln -sf "/usr/bin/psql-$version" "$version_dir/bin/psql"
ln -sf "/usr/bin/pg_restore-$version" "$version_dir/bin/pg_restore"
ln -sf "/usr/bin/createdb-$version" "$version_dir/bin/createdb"
ln -sf "/usr/bin/dropdb-$version" "$version_dir/bin/dropdb"
else
# Fallback to non-versioned names (latest version)
ln -sf "/usr/bin/pg_dump" "$version_dir/bin/pg_dump"
ln -sf "/usr/bin/pg_dumpall" "$version_dir/bin/pg_dumpall"
ln -sf "/usr/bin/psql" "$version_dir/bin/psql"
ln -sf "/usr/bin/pg_restore" "$version_dir/bin/pg_restore"
ln -sf "/usr/bin/createdb" "$version_dir/bin/createdb"
ln -sf "/usr/bin/dropdb" "$version_dir/bin/dropdb"
fi
echo "PostgreSQL $version client tools installed successfully"
else
echo "Warning: PostgreSQL $version client tools may not have installed correctly"
fi
echo
done
echo "Installation completed!"
echo "PostgreSQL client tools are available in: $POSTGRES_DIR"
echo
# List installed versions
echo "Installed PostgreSQL client versions:"
for version in $versions; do
version_dir="$POSTGRES_DIR/postgresql-$version"
if [ -f "$version_dir/bin/pg_dump" ]; then
echo " postgresql-$version: $version_dir/bin/"
fi
done
echo
echo "Usage example:"
echo " $POSTGRES_DIR/postgresql-15/bin/pg_dump --version"

View File

@@ -0,0 +1,143 @@
#!/bin/bash
set -e # Exit on any error
echo "Installing PostgreSQL client tools versions 13-17 for MacOS..."
echo
# Check if Homebrew is installed
if ! command -v brew &> /dev/null; then
echo "Error: This script requires Homebrew to be installed."
echo "Install Homebrew from: https://brew.sh/"
exit 1
fi
# Create postgresql directory
mkdir -p postgresql
# Get absolute path
POSTGRES_DIR="$(pwd)/postgresql"
echo "Installing PostgreSQL client tools to: $POSTGRES_DIR"
echo
# Update Homebrew
echo "Updating Homebrew..."
brew update
# Install build dependencies
echo "Installing build dependencies..."
brew install wget openssl readline zlib
# PostgreSQL source URLs
declare -A PG_URLS=(
["13"]="https://ftp.postgresql.org/pub/source/v13.16/postgresql-13.16.tar.gz"
["14"]="https://ftp.postgresql.org/pub/source/v14.13/postgresql-14.13.tar.gz"
["15"]="https://ftp.postgresql.org/pub/source/v15.8/postgresql-15.8.tar.gz"
["16"]="https://ftp.postgresql.org/pub/source/v16.4/postgresql-16.4.tar.gz"
["17"]="https://ftp.postgresql.org/pub/source/v17.0/postgresql-17.0.tar.gz"
)
# Create temporary build directory
BUILD_DIR="/tmp/postgresql_build_$$"
mkdir -p "$BUILD_DIR"
echo "Using temporary build directory: $BUILD_DIR"
echo
# Function to build PostgreSQL client tools
build_postgresql_client() {
local version=$1
local url=$2
local version_dir="$POSTGRES_DIR/postgresql-$version"
echo "Building PostgreSQL $version client tools..."
# Skip if already exists
if [ -f "$version_dir/bin/pg_dump" ]; then
echo "PostgreSQL $version already installed, skipping..."
return
fi
cd "$BUILD_DIR"
# Download source
echo " Downloading PostgreSQL $version source..."
wget -q "$url" -O "postgresql-$version.tar.gz"
# Extract
echo " Extracting source..."
tar -xzf "postgresql-$version.tar.gz"
cd "postgresql-$version"*
# Configure (client tools only)
echo " Configuring build..."
./configure \
--prefix="$version_dir" \
--with-openssl \
--with-readline \
--without-zlib \
--disable-server \
--disable-docs \
--quiet
# Build client tools only
echo " Building client tools (this may take a few minutes)..."
make -s -C src/bin install
make -s -C src/include install
make -s -C src/interfaces install
# Verify installation
if [ -f "$version_dir/bin/pg_dump" ]; then
echo " PostgreSQL $version client tools installed successfully"
# Test the installation
local pg_version=$("$version_dir/bin/pg_dump" --version | cut -d' ' -f3)
echo " Verified version: $pg_version"
else
echo " Warning: PostgreSQL $version may not have installed correctly"
fi
# Clean up source
cd "$BUILD_DIR"
rm -rf "postgresql-$version"*
echo
}
# Build each version
versions="13 14 15 16 17"
for version in $versions; do
url=${PG_URLS[$version]}
if [ -n "$url" ]; then
build_postgresql_client "$version" "$url"
else
echo "Warning: No URL defined for PostgreSQL $version"
fi
done
# Clean up build directory
echo "Cleaning up build directory..."
rm -rf "$BUILD_DIR"
echo "Installation completed!"
echo "PostgreSQL client tools are available in: $POSTGRES_DIR"
echo
# List installed versions
echo "Installed PostgreSQL client versions:"
for version in $versions; do
version_dir="$POSTGRES_DIR/postgresql-$version"
if [ -f "$version_dir/bin/pg_dump" ]; then
pg_version=$("$version_dir/bin/pg_dump" --version | cut -d' ' -f3)
echo " postgresql-$version ($pg_version): $version_dir/bin/"
fi
done
echo
echo "Usage example:"
echo " $POSTGRES_DIR/postgresql-15/bin/pg_dump --version"
echo
echo "To add a specific version to your PATH temporarily:"
echo " export PATH=\"$POSTGRES_DIR/postgresql-15/bin:\$PATH\""

View File

@@ -0,0 +1,101 @@
@echo off
setlocal enabledelayedexpansion
echo Downloading and installing PostgreSQL versions 13-17 for Windows...
echo.
:: Create downloads and postgresql directories if they don't exist
if not exist "downloads" mkdir downloads
if not exist "postgresql" mkdir postgresql
:: Get the absolute path to the postgresql directory
set "POSTGRES_DIR=%cd%\postgresql"
cd downloads
:: PostgreSQL download URLs for Windows x64
set "BASE_URL=https://get.enterprisedb.com/postgresql"
:: Define versions and their corresponding download URLs
set "PG13_URL=%BASE_URL%/postgresql-13.16-1-windows-x64.exe"
set "PG14_URL=%BASE_URL%/postgresql-14.13-1-windows-x64.exe"
set "PG15_URL=%BASE_URL%/postgresql-15.8-1-windows-x64.exe"
set "PG16_URL=%BASE_URL%/postgresql-16.4-1-windows-x64.exe"
set "PG17_URL=%BASE_URL%/postgresql-17.0-1-windows-x64.exe"
:: Array of versions
set "versions=13 14 15 16 17"
:: Download and install each version
for %%v in (%versions%) do (
echo Processing PostgreSQL %%v...
set "filename=postgresql-%%v-windows-x64.exe"
set "install_dir=%POSTGRES_DIR%\postgresql-%%v"
:: Check if already installed
if exist "!install_dir!" (
echo PostgreSQL %%v already installed, skipping...
) else (
:: Download if not exists
if not exist "!filename!" (
echo Downloading PostgreSQL %%v...
powershell -Command "& {$ProgressPreference = 'SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $uri = '!PG%%v_URL!'; $file = '!filename!'; $client = New-Object System.Net.WebClient; $client.Headers.Add('User-Agent', 'Mozilla/5.0'); $client.add_DownloadProgressChanged({param($s,$e) $pct = $e.ProgressPercentage; $recv = [math]::Round($e.BytesReceived/1MB,1); $total = [math]::Round($e.TotalBytesToReceive/1MB,1); Write-Host ('{0}%% - {1} MB / {2} MB' -f $pct, $recv, $total) -NoNewline; Write-Host ('`r') -NoNewline}); try {Write-Host 'Starting download...'; $client.DownloadFile($uri, $file); Write-Host ''; Write-Host 'Download completed!'} finally {$client.Dispose()}}"
if !errorlevel! neq 0 (
echo Failed to download PostgreSQL %%v
goto :next_version
)
echo PostgreSQL %%v downloaded successfully
) else (
echo PostgreSQL %%v already downloaded
)
:: Install PostgreSQL client tools only
echo Installing PostgreSQL %%v client tools to !install_dir!...
echo This may take up to 10 minutes even on powerful machines, please wait...
:: First try: Install with component selection
start /wait "" "!filename!" --mode unattended --unattendedmodeui none --prefix "!install_dir!" --disable-components server,pgAdmin,stackbuilder --enable-components commandlinetools
:: Check if installation actually worked by looking for pg_dump.exe
if exist "!install_dir!\bin\pg_dump.exe" (
echo PostgreSQL %%v client tools installed successfully
) else (
echo Component selection failed, trying full installation...
echo This may take up to 10 minutes even on powerful machines, please wait...
:: Fallback: Install everything but without starting services
start /wait "" "!filename!" --mode unattended --unattendedmodeui none --prefix "!install_dir!" --datadir "!install_dir!\data" --servicename "postgresql-%%v" --serviceaccount "NetworkService" --superpassword "postgres" --serverport 543%%v --extract-only 1
:: Check again
if exist "!install_dir!\bin\pg_dump.exe" (
echo PostgreSQL %%v installed successfully
) else (
echo Failed to install PostgreSQL %%v - No files found in installation directory
echo Checking what was created:
if exist "!install_dir!" (
powershell -Command "Get-ChildItem '!install_dir!' -Recurse | Select-Object -First 10 | ForEach-Object { $_.FullName }"
) else (
echo Installation directory was not created
)
)
)
)
:next_version
echo.
)
echo.
echo Installation process completed!
echo PostgreSQL versions are installed in: %POSTGRES_DIR%
echo.
:: List installed versions
echo Installed PostgreSQL versions:
if exist "postgresql" (
dir /b "postgresql\postgresql-*" 2>nul
) else (
echo No PostgreSQL installations found
)
pause

90
backend/tools/readme.md Normal file
View File

@@ -0,0 +1,90 @@
This directory is needed only for development.
We have to download and install all the PostgreSQL versions from 13 to 17 locally.
This is needed so we can call pg_dump, pg_dumpall, etc. on each version of the PostgreSQL database.
You do not need to install PostgreSQL fully with all the components.
We only need the client tools (pg_dump, pg_dumpall, psql, etc.) for each version.
We have to install the following:
- PostgreSQL 13
- PostgreSQL 14
- PostgreSQL 15
- PostgreSQL 16
- PostgreSQL 17
## Installation
Run the appropriate download script for your platform:
### Windows
```cmd
download_windows.bat
```
### Linux (Debian/Ubuntu)
```bash
chmod +x download_linux.sh
./download_linux.sh
```
### MacOS
```bash
chmod +x download_macos.sh
./download_macos.sh
```
## Platform-Specific Notes
### Windows
- Downloads official PostgreSQL installers from EnterpriseDB
- Installs client tools only (no server components)
- May require administrator privileges during installation
### Linux (Debian/Ubuntu)
- Uses the official PostgreSQL APT repository
- Requires sudo privileges to install packages
- Creates symlinks in version-specific directories for consistency
### MacOS
- Requires Homebrew to be installed
- Compiles PostgreSQL from source (client tools only)
- Takes longer than other platforms due to compilation
## Manual Installation
If something goes wrong with the automated scripts, install manually.
The final directory structure should match:
```
./tools/postgresql/postgresql-{version}/bin/pg_dump
./tools/postgresql/postgresql-{version}/bin/pg_dumpall
./tools/postgresql/postgresql-{version}/bin/psql
```
For example:
- `./tools/postgresql/postgresql-13/bin/pg_dump`
- `./tools/postgresql/postgresql-14/bin/pg_dump`
- `./tools/postgresql/postgresql-15/bin/pg_dump`
- `./tools/postgresql/postgresql-16/bin/pg_dump`
- `./tools/postgresql/postgresql-17/bin/pg_dump`
## Usage
After installation, you can use version-specific tools:
```bash
# Windows
./postgresql/postgresql-15/bin/pg_dump.exe --version
# Linux/MacOS
./postgresql/postgresql-15/bin/pg_dump --version
```

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "3"
services:
postgresus-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "4006:4006"
postgresus-backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "4005:4005"
volumes:
- ./postgresus-data:/postgresus-data
postgresus-db:
env_file:
- .env
image: postgres:17
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- ./pgdata:/var/lib/postgresql/data
container_name: postgresus-db
command: -p 5437
shm_size: 10gb

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
frontend/.prettierrc.js Normal file
View File

@@ -0,0 +1,24 @@
export default {
semi: true,
trailingComma: 'all',
singleQuote: true,
printWidth: 100,
tabWidth: 2,
arrowParens: 'always',
importOrder: [
'<THIRD_PARTY_MODULES>',
'.+.(svg|png|jpeg|jpg|webp|css)$',
'^@/constants',
'app/(.*)$',
'1_pages/(.*)$',
'2_widgets/(.*)$',
'3_features/(.*)$',
'4_entity/(.*)$',
'5_shared/(.*)$',
'(presentation|hooks|components)/(.*)$',
'^[./]',
],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
plugins: ['@trivago/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'],
};

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:18-alpine
EXPOSE 4006
WORKDIR /app
COPY package*.json ./
RUN npm ci && npm install -g serve
COPY . .
RUN npm run build
CMD ["serve", "-s", "dist", "-l", "4006"]

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactDom from 'eslint-plugin-react-dom';
import reactX from 'eslint-plugin-react-x';
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
});
```

35
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,35 @@
import js from '@eslint/js';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
react: react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
...react.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'react/react-in-jsx-scope': 'off',
'react-hooks/exhaustive-deps': 'off',
},
},
);

Some files were not shown because too many files have changed in this diff Show More