mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
FEATURE (init): Make internal project public
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# docker-compose.yml
|
||||
DB_NAME=postgresus
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=Q1234567
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
postgresus_data/
|
||||
.env
|
||||
pgdata/
|
||||
14
backend/.cursor/rules/codestyle-rule.mdc
Normal file
14
backend/.cursor/rules/codestyle-rule.mdc
Normal 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(...) ...
|
||||
}
|
||||
7
backend/.cursor/rules/comments.mdc
Normal file
7
backend/.cursor/rules/comments.mdc
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Do not write obsious comments.
|
||||
Write meaningful code, give meaningful names
|
||||
55
backend/.cursor/rules/controllers-rule.mdc
Normal file
55
backend/.cursor/rules/controllers-rule.mdc
Normal 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")
|
||||
74
backend/.cursor/rules/di-rule.mdc
Normal file
74
backend/.cursor/rules/di-rule.mdc
Normal 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
|
||||
}
|
||||
27
backend/.cursor/rules/migrations-rule.mdc
Normal file
27
backend/.cursor/rules/migrations-rule.mdc
Normal 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);
|
||||
6
backend/.cursor/rules/time-rule.mdc
Normal file
6
backend/.cursor/rules/time-rule.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Always use time.Now().UTC() instead of time.Now()
|
||||
13
backend/.env.example
Normal file
13
backend/.env.example
Normal 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
12
backend/.gitignore
vendored
Normal 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
19
backend/.golangci.yml
Normal 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
|
||||
15
backend/.pre-commit-config.yaml
Normal file
15
backend/.pre-commit-config.yaml
Normal 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
44
backend/Dockerfile
Normal 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
68
backend/README.md
Normal 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
188
backend/cmd/main.go
Normal 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))
|
||||
}
|
||||
20
backend/docker-compose.yml.example
Normal file
20
backend/docker-compose.yml.example
Normal 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
81
backend/go.mod
Normal 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
214
backend/go.sum
Normal 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=
|
||||
120
backend/internal/config/config.go
Normal file
120
backend/internal/config/config.go
Normal 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!")
|
||||
}
|
||||
23
backend/internal/config/signals.go
Normal file
23
backend/internal/config/signals.go
Normal 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
|
||||
}
|
||||
37
backend/internal/downdetect/controller.go
Normal file
37
backend/internal/downdetect/controller.go
Normal 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"})
|
||||
}
|
||||
10
backend/internal/downdetect/di.go
Normal file
10
backend/internal/downdetect/di.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package downdetect
|
||||
|
||||
var downdetectService = &DowndetectService{}
|
||||
var downdetectController = &DowndetectController{
|
||||
downdetectService,
|
||||
}
|
||||
|
||||
func GetDowndetectController() *DowndetectController {
|
||||
return downdetectController
|
||||
}
|
||||
17
backend/internal/downdetect/service.go
Normal file
17
backend/internal/downdetect/service.go
Normal 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
|
||||
}
|
||||
161
backend/internal/features/backups/background_service.go
Normal file
161
backend/internal/features/backups/background_service.go
Normal 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
|
||||
}
|
||||
145
backend/internal/features/backups/controller.go
Normal file
145
backend/internal/features/backups/controller.go
Normal 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"`
|
||||
}
|
||||
52
backend/internal/features/backups/di.go
Normal file
52
backend/internal/features/backups/di.go
Normal 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
|
||||
}
|
||||
10
backend/internal/features/backups/enums.go
Normal file
10
backend/internal/features/backups/enums.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package backups
|
||||
|
||||
type BackupStatus string
|
||||
|
||||
const (
|
||||
BackupStatusInProgress BackupStatus = "IN_PROGRESS"
|
||||
BackupStatusCompleted BackupStatus = "COMPLETED"
|
||||
BackupStatusFailed BackupStatus = "FAILED"
|
||||
BackupStatusDeleted BackupStatus = "DELETED"
|
||||
)
|
||||
41
backend/internal/features/backups/model.go
Normal file
41
backend/internal/features/backups/model.go
Normal 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.
|
||||
}
|
||||
}
|
||||
137
backend/internal/features/backups/repository.go
Normal file
137
backend/internal/features/backups/repository.go
Normal 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
|
||||
}
|
||||
319
backend/internal/features/backups/service.go
Normal file
319
backend/internal/features/backups/service.go
Normal 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(
|
||||
¬ifier,
|
||||
title,
|
||||
message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) {
|
||||
return s.backupRepository.FindByID(backupID)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
365
backend/internal/features/databases/controller.go
Normal file
365
backend/internal/features/databases/controller.go
Normal 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})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
23
backend/internal/features/databases/di.go
Normal file
23
backend/internal/features/databases/di.go
Normal 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
|
||||
}
|
||||
1
backend/internal/features/databases/dto.go
Normal file
1
backend/internal/features/databases/dto.go
Normal file
@@ -0,0 +1 @@
|
||||
package databases
|
||||
62
backend/internal/features/databases/enums.go
Normal file
62
backend/internal/features/databases/enums.go
Normal 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"
|
||||
)
|
||||
15
backend/internal/features/databases/interfaces.go
Normal file
15
backend/internal/features/databases/interfaces.go
Normal 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
|
||||
}
|
||||
116
backend/internal/features/databases/model.go
Normal file
116
backend/internal/features/databases/model.go
Normal 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))
|
||||
}
|
||||
191
backend/internal/features/databases/repository.go
Normal file
191
backend/internal/features/databases/repository.go
Normal 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
|
||||
}
|
||||
183
backend/internal/features/databases/service.go
Normal file
183
backend/internal/features/databases/service.go
Normal 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)
|
||||
}
|
||||
10
backend/internal/features/intervals/enums.go
Normal file
10
backend/internal/features/intervals/enums.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package intervals
|
||||
|
||||
type IntervalType string
|
||||
|
||||
const (
|
||||
IntervalHourly IntervalType = "HOURLY"
|
||||
IntervalDaily IntervalType = "DAILY"
|
||||
IntervalWeekly IntervalType = "WEEKLY"
|
||||
IntervalMonthly IntervalType = "MONTHLY"
|
||||
)
|
||||
192
backend/internal/features/intervals/model.go
Normal file
192
backend/internal/features/intervals/model.go
Normal 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())
|
||||
}
|
||||
337
backend/internal/features/intervals/model_test.go
Normal file
337
backend/internal/features/intervals/model_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
225
backend/internal/features/notifiers/controller.go
Normal file
225
backend/internal/features/notifiers/controller.go
Normal 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(¬ifier); 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, ¬ifier); 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(¬ifier); 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(¬ifier); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "test notification sent successfully"})
|
||||
}
|
||||
20
backend/internal/features/notifiers/di.go
Normal file
20
backend/internal/features/notifiers/di.go
Normal 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
|
||||
}
|
||||
8
backend/internal/features/notifiers/enums.go
Normal file
8
backend/internal/features/notifiers/enums.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package notifiers
|
||||
|
||||
type NotifierType string
|
||||
|
||||
const (
|
||||
NotifierTypeEmail NotifierType = "EMAIL"
|
||||
NotifierTypeTelegram NotifierType = "TELEGRAM"
|
||||
)
|
||||
7
backend/internal/features/notifiers/interfaces.go
Normal file
7
backend/internal/features/notifiers/interfaces.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package notifiers
|
||||
|
||||
type NotificationSender interface {
|
||||
Send(heading string, message string) error
|
||||
|
||||
Validate() error
|
||||
}
|
||||
56
backend/internal/features/notifiers/model.go
Normal file
56
backend/internal/features/notifiers/model.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
113
backend/internal/features/notifiers/repository.go
Normal file
113
backend/internal/features/notifiers/repository.go
Normal 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(¬ifier).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ¬ifier, 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(¬ifiers).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
|
||||
})
|
||||
}
|
||||
140
backend/internal/features/notifiers/service.go
Normal file
140
backend/internal/features/notifiers/service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
backend/internal/features/restores/background_service.go
Normal file
38
backend/internal/features/restores/background_service.go
Normal 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
|
||||
}
|
||||
99
backend/internal/features/restores/controller.go
Normal file
99
backend/internal/features/restores/controller.go
Normal 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"})
|
||||
}
|
||||
31
backend/internal/features/restores/di.go
Normal file
31
backend/internal/features/restores/di.go
Normal 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
|
||||
}
|
||||
9
backend/internal/features/restores/dto.go
Normal file
9
backend/internal/features/restores/dto.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package restores
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
)
|
||||
|
||||
type RestoreBackupRequest struct {
|
||||
PostgresqlDatabase *postgresql.PostgresqlDatabase `json:"postgresqlDatabase"`
|
||||
}
|
||||
9
backend/internal/features/restores/enums/enums.go
Normal file
9
backend/internal/features/restores/enums/enums.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package enums
|
||||
|
||||
type RestoreStatus string
|
||||
|
||||
const (
|
||||
RestoreStatusInProgress RestoreStatus = "IN_PROGRESS"
|
||||
RestoreStatusCompleted RestoreStatus = "COMPLETED"
|
||||
RestoreStatusFailed RestoreStatus = "FAILED"
|
||||
)
|
||||
25
backend/internal/features/restores/models/model.go
Normal file
25
backend/internal/features/restores/models/model.go
Normal 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()"`
|
||||
}
|
||||
80
backend/internal/features/restores/repository.go
Normal file
80
backend/internal/features/restores/repository.go
Normal 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
|
||||
}
|
||||
132
backend/internal/features/restores/service.go
Normal file
132
backend/internal/features/restores/service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
224
backend/internal/features/storages/controller.go
Normal file
224
backend/internal/features/storages/controller.go
Normal 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"})
|
||||
}
|
||||
22
backend/internal/features/storages/di.go
Normal file
22
backend/internal/features/storages/di.go
Normal 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
|
||||
}
|
||||
8
backend/internal/features/storages/enums.go
Normal file
8
backend/internal/features/storages/enums.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package storages
|
||||
|
||||
type StorageType string
|
||||
|
||||
const (
|
||||
StorageTypeLocal StorageType = "LOCAL"
|
||||
StorageTypeS3 StorageType = "S3"
|
||||
)
|
||||
19
backend/internal/features/storages/interfaces.go
Normal file
19
backend/internal/features/storages/interfaces.go
Normal 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
|
||||
}
|
||||
70
backend/internal/features/storages/model.go
Normal file
70
backend/internal/features/storages/model.go
Normal 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))
|
||||
}
|
||||
}
|
||||
113
backend/internal/features/storages/repository.go
Normal file
113
backend/internal/features/storages/repository.go
Normal 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
|
||||
})
|
||||
}
|
||||
108
backend/internal/features/storages/service.go
Normal file
108
backend/internal/features/storages/service.go
Normal 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)
|
||||
}
|
||||
176
backend/internal/features/storages/storages/local/model.go
Normal file
176
backend/internal/features/storages/storages/local/model.go
Normal 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
|
||||
}
|
||||
175
backend/internal/features/storages/storages/s3/model.go
Normal file
175
backend/internal/features/storages/storages/s3/model.go
Normal 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
|
||||
}
|
||||
86
backend/internal/features/users/controller.go
Normal file
86
backend/internal/features/users/controller.go
Normal 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})
|
||||
}
|
||||
23
backend/internal/features/users/di.go
Normal file
23
backend/internal/features/users/di.go
Normal 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
|
||||
}
|
||||
18
backend/internal/features/users/dto.go
Normal file
18
backend/internal/features/users/dto.go
Normal 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"`
|
||||
}
|
||||
7
backend/internal/features/users/enums/user_role.go
Normal file
7
backend/internal/features/users/enums/user_role.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package user_enums
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
UserRoleAdmin UserRole = "ADMIN"
|
||||
)
|
||||
9
backend/internal/features/users/models/secret_key.go
Normal file
9
backend/internal/features/users/models/secret_key.go
Normal 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"
|
||||
}
|
||||
21
backend/internal/features/users/models/user.go
Normal file
21
backend/internal/features/users/models/user.go
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
153
backend/internal/features/users/service.go
Normal file
153
backend/internal/features/users/service.go
Normal 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
|
||||
}
|
||||
55
backend/internal/storage/storage.go
Normal file
55
backend/internal/storage/storage.go
Normal 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
8
backend/internal/util/env/enums.go
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
package env_utils
|
||||
|
||||
type EnvMode string
|
||||
|
||||
const (
|
||||
EnvModeDevelopment EnvMode = "development"
|
||||
EnvModeProduction EnvMode = "production"
|
||||
)
|
||||
7
backend/internal/util/files/cleaner.go
Normal file
7
backend/internal/util/files/cleaner.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package files_utils
|
||||
|
||||
import "os"
|
||||
|
||||
func CleanFolder(folder string) error {
|
||||
return os.RemoveAll(folder)
|
||||
}
|
||||
48
backend/internal/util/logger/logger.go
Normal file
48
backend/internal/util/logger/logger.go
Normal 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
|
||||
}
|
||||
18
backend/internal/util/tools/enums.go
Normal file
18
backend/internal/util/tools/enums.go
Normal 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"
|
||||
)
|
||||
164
backend/internal/util/tools/postgresql.go
Normal file
164
backend/internal/util/tools/postgresql.go
Normal 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))
|
||||
}
|
||||
}
|
||||
260
backend/migrations/20250605090323_init.sql
Normal file
260
backend/migrations/20250605090323_init.sql
Normal 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
2
backend/tools/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
postgresql
|
||||
downloads
|
||||
101
backend/tools/download_linux.sh
Normal file
101
backend/tools/download_linux.sh
Normal 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"
|
||||
143
backend/tools/download_macos.sh
Normal file
143
backend/tools/download_macos.sh
Normal 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\""
|
||||
101
backend/tools/download_windows.bat
Normal file
101
backend/tools/download_windows.bat
Normal 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
90
backend/tools/readme.md
Normal 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
32
docker-compose.yml
Normal 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
24
frontend/.gitignore
vendored
Normal 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
24
frontend/.prettierrc.js
Normal 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
14
frontend/Dockerfile
Normal 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
54
frontend/README.md
Normal 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
35
frontend/eslint.config.js
Normal 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
Reference in New Issue
Block a user