merge services api and nginx

This commit is contained in:
2025-10-05 23:27:19 +05:00
parent fd329f120d
commit c73701cf5c
29 changed files with 50 additions and 4 deletions
+7
View File
@@ -0,0 +1,7 @@
# DB environment variabels
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=mydb
APP_PORT=8080
@@ -0,0 +1,33 @@
# Билд стадия
FROM golang:1.25.1-alpine AS builder
WORKDIR /app
# Копируем зависимости
COPY go.mod go.sum ./
RUN go mod download
# Копируем исходный код
COPY . .
# Собираем приложение
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api
# Финальная стадия
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Копируем бинарник из builder стадии
COPY --from=builder /app/main .
# Копируем миграции
COPY --from=builder /app/migrations ./migrations
# Экспозим порт
EXPOSE 8080
# Запускаем приложение
CMD ["./main"]
@@ -0,0 +1,27 @@
package main
import (
"log"
"serv_golang_rest_api/internal/config"
"serv_golang_rest_api/internal/server"
"serv_golang_rest_api/pkg/database"
)
func main() {
// Загрузка конфигурации
cfg := config.Load()
// Подключение к БД
db, err := database.NewPostgresConnection(cfg)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
// Создание и запуск сервера
srv := server.New(db)
log.Printf("Server starting on port %s", cfg.AppPort)
if err := srv.Run(cfg.AppPort); err != nil {
log.Fatal("Failed to start server:", err)
}
}
@@ -0,0 +1,53 @@
services:
api:
build:
context: ./serv_golang_rest_api
dockerfile: Dockerfile
ports:
- "8888:8080"
container_name: serv_golang_rest_api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
# Database connection settings
DB_HOST: db
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: mydb
APP_PORT: 8080
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
networks:
app-network:
driver: bridge
+26
View File
@@ -0,0 +1,26 @@
module serv_golang_rest_api
go 1.25.1
require (
github.com/golang-jwt/jwt/v4 v4.5.2
golang.org/x/oauth2 v0.31.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
)
require cloud.google.com/go/compute/metadata v0.3.0 // indirect
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // 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
golang.org/x/crypto v0.42.0
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+46
View File
@@ -0,0 +1,46 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
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/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
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/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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
@@ -0,0 +1,30 @@
package config
import "os"
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
AppPort string
}
func Load() *Config {
return &Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "postgres"),
DBName: getEnv("DB_NAME", "mydb"),
AppPort: getEnv("APP_PORT", "8080"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
@@ -0,0 +1,36 @@
// config/oauth.go
package config
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/yandex"
"golang.org/x/oauth2/vk"
)
var (
GoogleOAuthConfig = &oauth2.Config{
ClientID: "your-google-client-id",
ClientSecret: "your-google-client-secret",
RedirectURL: "http://localhost:8080/auth/google/callback",
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"},
Endpoint: google.Endpoint,
}
YandexOAuthConfig = &oauth2.Config{
ClientID: "your-yandex-client-id",
ClientSecret: "your-yandex-client-secret",
RedirectURL: "http://localhost:8080/auth/yandex/callback",
Scopes: []string{"login:email", "login:info", "login:avatar"},
Endpoint: yandex.Endpoint,
}
VKOAuthConfig = &oauth2.Config{
ClientID: "your-vk-client-id",
ClientSecret: "your-vk-client-secret",
RedirectURL: "http://localhost:8080/auth/vk/callback",
Scopes: []string{"email", "photos"},
Endpoint: vk.Endpoint,
}
)
@@ -0,0 +1,104 @@
// handlers/auth.go
package handlers
import (
"net/http"
"serv_golang_rest_api/internal/models"
"serv_golang_rest_api/internal/utils"
"gorm.io/gorm"
)
type AuthHandler struct {
DB *gorm.DB
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
Name string `json:"name" validate:"required"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Invalid request")
return
}
// Проверяем, существует ли пользователь
var existingUser models.User
if err := h.DB.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
utils.WriteError(w, http.StatusConflict, "User already exists")
return
}
// Хешируем пароль
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error creating user")
return
}
// Создаем пользователя
user := models.User{
Email: req.Email,
Password: hashedPassword,
Name: req.Name,
}
if err := h.DB.Create(&user).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error creating user")
return
}
// Генерируем JWT токен
token, err := utils.GenerateJWT(user.ID, user.Email)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error generating token")
return
}
utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Invalid request")
return
}
// Ищем пользователя
var user models.User
if err := h.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
utils.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
// Проверяем пароль
if !utils.CheckPasswordHash(req.Password, user.Password) {
utils.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
// Генерируем JWT токен
token, err := utils.GenerateJWT(user.ID, user.Email)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error generating token")
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
@@ -0,0 +1,25 @@
package handlers
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func CommonMiddleware() []func(http.Handler) http.Handler {
return []func(http.Handler) http.Handler{
middleware.Logger,
middleware.Recoverer,
middleware.Timeout(60 * time.Second),
cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}),
}
}
@@ -0,0 +1,130 @@
// handlers/oauth.go
package handlers
import (
"encoding/json"
"net/http"
"serv_golang_rest_api/internal/config"
"serv_golang_rest_api/internal/models"
"serv_golang_rest_api/internal/utils"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
type OAuthHandler struct {
DB *gorm.DB
}
type GoogleUserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *OAuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
url := config.GoogleOAuthConfig.AuthCodeURL("state")
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func (h *OAuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
token, err := config.GoogleOAuthConfig.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to exchange token")
return
}
client := config.GoogleOAuthConfig.Client(r.Context(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to get user info")
return
}
defer resp.Body.Close()
var userInfo GoogleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to decode user info")
return
}
// Создаем или находим пользователя
user, err := h.findOrCreateOAuthUser("google", userInfo.ID, userInfo.Email, userInfo.Name, token)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error processing user")
return
}
jwtToken, err := utils.GenerateJWT(user.ID, user.Email)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error generating token")
return
}
// Редирект или возврат токена
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"token": jwtToken,
"user": user,
})
}
// Аналогичные методы для Yandex и VK...
func (h *OAuthHandler) findOrCreateOAuthUser(provider, providerID, email, name string, token *oauth2.Token) (*models.User, error) {
var oauthProvider models.OAuthProvider
err := h.DB.Where("provider = ? AND provider_id = ?", provider, providerID).
Preload("User").
First(&oauthProvider).Error
if err == nil {
// Обновляем токены существующей привязки
oauthProvider.AccessToken = token.AccessToken
oauthProvider.RefreshToken = token.RefreshToken
oauthProvider.ExpiresAt = token.Expiry
if err := h.DB.Save(&oauthProvider).Error; err != nil {
return nil, err
}
var user models.User
if err := h.DB.First(&user, oauthProvider.UserID).Error; err != nil {
return nil, err
}
return &user, nil
}
// Ищем пользователя по email
var user models.User
err = h.DB.Where("email = ?", email).First(&user).Error
if err != nil {
// Создаем нового пользователя
user = models.User{
Email: email,
Name: name,
Password: utils.GenerateRandomPassword(),
}
if err := h.DB.Create(&user).Error; err != nil {
return nil, err
}
}
// Создаем новую привязку OAuth с токенами
oauthProvider = models.OAuthProvider{
UserID: user.ID,
Provider: provider,
ProviderID: providerID,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresAt: token.Expiry,
}
if err := h.DB.Create(&oauthProvider).Error; err != nil {
return nil, err
}
return &user, nil
}
@@ -0,0 +1,125 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"serv_golang_rest_api/internal/config"
"serv_golang_rest_api/internal/utils"
)
// VKUserInfo представляет данные пользователя от VK
type VKUserInfo struct {
Response []struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Photo string `json:"photo_200"`
} `json:"response"`
}
// VKEmailResponse представляет ответ с email от VK
type VKEmailResponse struct {
Email string `json:"email"`
}
// VKLogin initiates VK OAuth flow
func (h *OAuthHandler) VKLogin(w http.ResponseWriter, r *http.Request) {
url := config.VKOAuthConfig.AuthCodeURL("state")
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// VKCallback handles VK OAuth callback
func (h *OAuthHandler) VKCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
token, err := config.VKOAuthConfig.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to exchange token: "+err.Error())
return
}
// VK не возвращает email в основном токене, нужно получить его отдельно
email, err := h.getVKEmail(token.AccessToken)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to get email from VK: "+err.Error())
return
}
client := config.VKOAuthConfig.Client(r.Context(), token)
// Получаем основную информацию о пользователе
userInfoURL := fmt.Sprintf("https://api.vk.com/method/users.get?fields=photo_200,email&v=5.131&access_token=%s", token.AccessToken)
resp, err := client.Get(userInfoURL)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to get user info: "+err.Error())
return
}
defer resp.Body.Close()
var vkUserInfo VKUserInfo
if err := json.NewDecoder(resp.Body).Decode(&vkUserInfo); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to decode user info: "+err.Error())
return
}
if len(vkUserInfo.Response) == 0 {
utils.WriteError(w, http.StatusBadRequest, "No user data received from VK")
return
}
vkUser := vkUserInfo.Response[0]
userID := fmt.Sprintf("%d", vkUser.ID)
name := vkUser.FirstName + " " + vkUser.LastName
// Используем email из отдельного запроса
if email == "" && vkUser.Email != "" {
email = vkUser.Email
}
// Если email все еще пустой, создаем временный
if email == "" {
email = fmt.Sprintf("vk_%s@temp.vk", userID)
}
// Создаем или находим пользователя
user, err := h.findOrCreateOAuthUser("vk", userID, email, name, token)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error processing user: "+err.Error())
return
}
jwtToken, err := utils.GenerateJWT(user.ID, user.Email)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error generating token: "+err.Error())
return
}
h.handleOAuthSuccess(w, r, jwtToken, user)
}
// getVKEmail получает email из VK OAuth
func (h *OAuthHandler) getVKEmail(accessToken string) (string, error) {
// VK возвращает email в ответе на запрос токена, но если его нет,
// можно попробовать получить через API
emailURL := fmt.Sprintf("https://api.vk.com/method/account.getProfileInfo?v=5.131&access_token=%s", accessToken)
resp, err := http.Get(emailURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
var emailResp struct {
Response struct {
Email string `json:"email"`
} `json:"response"`
}
if err := json.NewDecoder(resp.Body).Decode(&emailResp); err != nil {
return "", err
}
return emailResp.Response.Email, nil
}
@@ -0,0 +1,100 @@
package handlers
import (
"encoding/json"
"net/http"
"serv_golang_rest_api/internal/config"
"serv_golang_rest_api/internal/models"
"serv_golang_rest_api/internal/utils"
"golang.org/x/oauth2"
)
type YandexUserInfo struct {
ID string `json:"id"`
Login string `json:"login"`
Email string `json:"default_email"`
DisplayName string `json:"display_name"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
RealName string `json:"real_name"`
IsAvatarEmpty bool `json:"is_avatar_empty"`
}
// YandexLogin initiates Yandex OAuth flow
func (h *OAuthHandler) YandexLogin(w http.ResponseWriter, r *http.Request) {
url := config.YandexOAuthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// YandexCallback handles Yandex OAuth callback
func (h *OAuthHandler) YandexCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if state != "state" {
utils.WriteError(w, http.StatusBadRequest, "Invalid state parameter")
return
}
token, err := config.YandexOAuthConfig.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to exchange token: "+err.Error())
return
}
client := config.YandexOAuthConfig.Client(r.Context(), token)
// Получаем информацию о пользователе
resp, err := client.Get("https://login.yandex.ru/info?format=json")
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to get user info: "+err.Error())
return
}
defer resp.Body.Close()
var userInfo YandexUserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Failed to decode user info: "+err.Error())
return
}
// Формируем имя пользователя
name := h.getYandexUserName(userInfo)
// Создаем или находим пользователя
user, err := h.findOrCreateOAuthUser("yandex", userInfo.ID, userInfo.Email, name, token)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error processing user: "+err.Error())
return
}
jwtToken, err := utils.GenerateJWT(user.ID, user.Email)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Error generating token: "+err.Error())
return
}
h.handleOAuthSuccess(w, r, jwtToken, user)
}
func (h *OAuthHandler) handleOAuthSuccess(w http.ResponseWriter, r *http.Request, jwtToken string, user *models.User) {
panic("unimplemented")
}
// getYandexUserName формирует имя пользователя из данных Yandex
func (h *OAuthHandler) getYandexUserName(userInfo YandexUserInfo) string {
if userInfo.RealName != "" {
return userInfo.RealName
}
if userInfo.DisplayName != "" {
return userInfo.DisplayName
}
if userInfo.FirstName != "" && userInfo.LastName != "" {
return userInfo.FirstName + " " + userInfo.LastName
}
if userInfo.FirstName != "" {
return userInfo.FirstName
}
return userInfo.Login
}
@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"serv_golang_rest_api/internal/models"
"serv_golang_rest_api/internal/service"
)
type UserHandler struct {
userService *service.UserService
}
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{userService: userService}
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req models.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.userService.CreateUser(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.userService.GetUserByID(uint(id))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) GetAllUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.userService.GetAllUsers()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
@@ -0,0 +1,35 @@
// middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"serv_golang_rest_api/internal/utils"
)
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
utils.WriteError(w, http.StatusUnauthorized, "Authorization header required")
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
utils.WriteError(w, http.StatusUnauthorized, "Invalid authorization format")
return
}
claims, err := utils.ValidateJWT(parts[1])
if err != nil {
utils.WriteError(w, http.StatusUnauthorized, "Invalid token")
return
}
// Добавляем claims в контекст
ctx := context.WithValue(r.Context(), "userClaims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -0,0 +1,20 @@
package models
import (
"time"
"gorm.io/gorm"
)
type OAuthProvider struct {
ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id" gorm:"not null;index:idx_user_provider"` // Уникальный индекс с провайдером
Provider string `json:"provider" gorm:"not null;index:idx_user_provider;size:50"` // Ограничение длины
ProviderID string `json:"provider_id" gorm:"not null;uniqueIndex:uix_provider_id"` // Уникальный идентификатор
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // Добавлено для отслеживания изменений
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
@@ -0,0 +1,39 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primarykey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
Name string `json:"name" gorm:"size:100;not null"`
Email string `json:"email" gorm:"size:255;uniqueIndex;not null"`
Password string `json:"-" gorm:"size:255;not null"` // Пароль не возвращаем в JSON
// OAuth провайдеры
OAuthProviders []OAuthProvider `json:"-"`
}
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
}
type UpdateUserRequest struct {
Name string `json:"name" validate:"omitempty,min=2,max=100"`
Email string `json:"email" validate:"omitempty,email"`
}
type UserResponse struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
Email string `json:"email"`
}
@@ -0,0 +1,45 @@
package repository
import (
"serv_golang_rest_api/internal/models"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
var user models.User
err := r.db.First(&user, id).Error
return &user, err
}
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
var user models.User
err := r.db.Where("email = ?", email).First(&user).Error
return &user, err
}
func (r *UserRepository) FindAll() ([]models.User, error) {
var users []models.User
err := r.db.Find(&users).Error
return users, err
}
func (r *UserRepository) Update(user *models.User) error {
return r.db.Save(user).Error
}
func (r *UserRepository) Delete(id uint) error {
return r.db.Delete(&models.User{}, id).Error
}
@@ -0,0 +1,104 @@
package server
import (
"encoding/json"
"net/http"
"serv_golang_rest_api/internal/handlers"
"serv_golang_rest_api/internal/middleware"
"serv_golang_rest_api/internal/repository"
"serv_golang_rest_api/internal/service"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
type Server struct {
router *chi.Mux
db *gorm.DB
}
func New(db *gorm.DB) *Server {
s := &Server{
router: chi.NewRouter(),
db: db,
}
s.configureRouter(db)
return s
}
func (s *Server) configureRouter(db *gorm.DB) {
// Общие middleware
for _, middleware := range handlers.CommonMiddleware() {
s.router.Use(middleware)
}
// Health check
s.router.Get("/health", s.healthCheck)
// API routes
s.router.Route("/v1", func(r chi.Router) {
s.router.Get("/check", s.healthCheck)
s.setupUserRoutes(r, db)
})
}
func (s *Server) setupUserRoutes(r chi.Router, db *gorm.DB) {
userRepo := repository.NewUserRepository(s.db)
userService := service.NewUserService(userRepo)
userHandler := handlers.NewUserHandler(userService)
authHandler := &handlers.AuthHandler{DB: db}
oauthHandler := &handlers.OAuthHandler{DB: db}
// Публичные маршруты
r.Route("/auth", func(r chi.Router) {
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Get("/check", s.healthCheck)
// OAuth routes
r.Get("/google", oauthHandler.GoogleLogin)
r.Get("/google/callback", oauthHandler.GoogleCallback)
r.Get("/yandex", oauthHandler.YandexLogin)
r.Get("/yandex/callback", oauthHandler.YandexCallback)
r.Get("/vk", oauthHandler.VKLogin)
r.Get("/vk/callback", oauthHandler.VKCallback)
})
// Защищенные маршруты
r.Route("/api", func(r chi.Router) {
r.Use(middleware.AuthMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", userHandler.GetAllUsers)
r.Post("/", userHandler.CreateUser)
r.Get("/{id}", userHandler.GetUser)
r.Get("/check", s.healthCheck)
})
})
}
func (s *Server) healthCheck(w http.ResponseWriter, r *http.Request) {
// Проверяем соединение с БД
sqlDB, err := s.db.DB()
if err != nil {
http.Error(w, "Database connection error", http.StatusServiceUnavailable)
return
}
if err := sqlDB.Ping(); err != nil {
http.Error(w, "Database ping failed", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
"timestamp": http.TimeFormat,
})
}
func (s *Server) Run(port string) error {
return http.ListenAndServe(":"+port, s.router)
}
@@ -0,0 +1,77 @@
package service
import (
"errors"
"serv_golang_rest_api/internal/models"
"serv_golang_rest_api/internal/repository"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
userRepo *repository.UserRepository
}
func NewUserService(userRepo *repository.UserRepository) *UserService {
return &UserService{userRepo: userRepo}
}
func (s *UserService) CreateUser(req *models.CreateUserRequest) (*models.UserResponse, error) {
// Проверяем существует ли пользователь с таким email
existingUser, err := s.userRepo.FindByEmail(req.Email)
// Проверяем как на nil, так на пустой ID
if existingUser != nil && existingUser.ID != 0 {
return nil, errors.New("user with this email already exists")
}
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &models.User{
Name: req.Name,
Email: req.Email,
Password: string(hashedPassword),
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return s.toUserResponse(user), nil
}
func (s *UserService) GetUserByID(id uint) (*models.UserResponse, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, errors.New("user not found")
}
return s.toUserResponse(user), nil
}
func (s *UserService) GetAllUsers() ([]models.UserResponse, error) {
users, err := s.userRepo.FindAll()
if err != nil {
return nil, err
}
var responses []models.UserResponse
for _, user := range users {
responses = append(responses, *s.toUserResponse(&user))
}
return responses, nil
}
func (s *UserService) toUserResponse(user *models.User) *models.UserResponse {
return &models.UserResponse{
ID: user.ID,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Name: user.Name,
Email: user.Email,
}
}
@@ -0,0 +1,39 @@
// utils/errors.go
package utils
import (
"fmt"
"net/http"
)
// APIError представляет ошибку API
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("API Error %d: %s", e.Code, e.Message)
}
// ErrorResponse представляет стандартный ответ с ошибкой
type ErrorResponse struct {
Error bool `json:"error"`
Message string `json:"message"`
Code int `json:"code"`
}
// ValidationErrorResponse представляет ответ с ошибками валидации
type ValidationErrorResponse struct {
Error bool `json:"error"`
Message string `json:"message"`
Code int `json:"code"`
Errors map[string]string `json:"errors,omitempty"`
}
// Predefined errors
var (
ErrInvalidJSON = &APIError{Code: http.StatusBadRequest, Message: "Invalid JSON"}
ErrEmptyRequestBody = &APIError{Code: http.StatusBadRequest, Message: "Request body is empty"}
ErrRequestBodyTooLarge = &APIError{Code: http.StatusRequestEntityTooLarge, Message: "Request body too large"}
)
@@ -0,0 +1,115 @@
// utils/json.go
package utils
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// DecodeJSON декодирует JSON из тела запроса с валидацией
func DecodeJSON(r *http.Request, v interface{}) error {
// Ограничиваем размер тела запроса (например, 1MB)
maxBytes := int64(1_048_576) // 1MB
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // Запрещаем неизвестные поля
err := decoder.Decode(v)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
switch {
case err == io.EOF:
return &APIError{
Code: http.StatusBadRequest,
Message: "Request body is empty",
}
case err.Error() == "http: request body too large":
return &APIError{
Code: http.StatusRequestEntityTooLarge,
Message: fmt.Sprintf("Request body must not be larger than %d bytes", maxBytes),
}
case strings.HasPrefix(err.Error(), "json: unknown field"):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return &APIError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("Unknown field in JSON: %s", fieldName),
}
case errors.As(err, &syntaxError):
return &APIError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("Malformed JSON at position %d", syntaxError.Offset),
}
case errors.As(err, &unmarshalTypeError):
return &APIError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("Invalid value for field '%s'. Expected type %s", unmarshalTypeError.Field, unmarshalTypeError.Type),
}
case errors.As(err, &invalidUnmarshalError):
return &APIError{
Code: http.StatusInternalServerError,
Message: "Internal server error",
}
default:
return &APIError{
Code: http.StatusBadRequest,
Message: "Invalid JSON",
}
}
}
// Проверяем, что нет лишних данных после JSON
if err = decoder.Decode(&struct{}{}); err != io.EOF {
return &APIError{
Code: http.StatusBadRequest,
Message: "Request body must contain only single JSON object",
}
}
return nil
}
// WriteJSON записывает JSON ответ
func WriteJSON(w http.ResponseWriter, status int, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data == nil {
return nil
}
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(true) // Экранируем HTML для безопасности
return encoder.Encode(data)
}
// WriteError записывает ошибку в формате JSON
func WriteError(w http.ResponseWriter, status int, message string) {
errorResponse := ErrorResponse{
Error: true,
Message: message,
Code: status,
}
WriteJSON(w, status, errorResponse)
}
// WriteValidationError записывает ошибки валидации
func WriteValidationError(w http.ResponseWriter, errors map[string]string) {
errorResponse := ValidationErrorResponse{
Error: true,
Message: "Validation failed",
Code: http.StatusBadRequest,
Errors: errors,
}
WriteJSON(w, http.StatusBadRequest, errorResponse)
}
@@ -0,0 +1,49 @@
// utils/jwt.go
package utils
import (
"time"
"github.com/golang-jwt/jwt/v4"
)
var jwtSecret = []byte("your-secret-key") // вынеси в env variables
type Claims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
func GenerateJWT(userID uint, email string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func ValidateJWT(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, jwt.ErrSignatureInvalid
}
return claims, nil
}
@@ -0,0 +1,78 @@
// utils/oauth_utils.go
package utils
import (
"crypto/rand"
"fmt"
"serv_golang_rest_api/internal/models"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
type OAuthHandler struct {
DB *gorm.DB
}
// GenerateState generates a random state string for OAuth
func GenerateState() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func (h *OAuthHandler) findOrCreateOAuthUser(provider, providerID, email, name string, token *oauth2.Token) (*models.User, error) {
var oauthProvider models.OAuthProvider
err := h.DB.Where("provider = ? AND provider_id = ?", provider, providerID).
Preload("User").
First(&oauthProvider).Error
if err == nil {
// Обновляем токены существующей привязки
oauthProvider.AccessToken = token.AccessToken
oauthProvider.RefreshToken = token.RefreshToken
oauthProvider.ExpiresAt = token.Expiry
if err := h.DB.Save(&oauthProvider).Error; err != nil {
return nil, err
}
var user models.User
if err := h.DB.First(&user, oauthProvider.UserID).Error; err != nil {
return nil, err
}
return &user, nil
}
// Ищем пользователя по email
var user models.User
err = h.DB.Where("email = ?", email).First(&user).Error
if err != nil {
// Создаем нового пользователя
user = models.User{
Email: email,
Name: name,
Password: GenerateRandomPassword(),
}
if err := h.DB.Create(&user).Error; err != nil {
return nil, err
}
}
// Создаем новую привязку OAuth с токенами
oauthProvider = models.OAuthProvider{
UserID: user.ID,
Provider: provider,
ProviderID: providerID,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresAt: token.Expiry,
}
if err := h.DB.Create(&oauthProvider).Error; err != nil {
return nil, err
}
return &user, nil
}
@@ -0,0 +1,29 @@
// utils/password.go
package utils
import (
"crypto/rand"
"encoding/base64"
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// GenerateRandomPassword генерирует случайный пароль для OAuth пользователей
func GenerateRandomPassword() string {
bytes := make([]byte, 32) // 256 бит
_, err := rand.Read(bytes)
if err != nil {
// Fallback - используем временный пароль
return "temp_oauth_password_123"
}
return base64.URLEncoding.EncodeToString(bytes)
}
Binary file not shown.
@@ -0,0 +1,16 @@
-- Таблица пользователей
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE NULL
);
-- Индекс для быстрого поиска по email
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Индекс для мягкого удаления
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
@@ -0,0 +1,53 @@
package database
import (
"fmt"
"log"
"serv_golang_rest_api/internal/config"
"serv_golang_rest_api/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func NewPostgresConnection(cfg *config.Config) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Автомиграция
if err := autoMigrate(db); err != nil {
return nil, err
}
log.Println("Successfully connected to database")
return db, nil
}
func autoMigrate(db *gorm.DB) error {
// Сначала добавляем колонку как NULLABLE
if err := db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(255)`).Error; err != nil {
return err
}
// Затем обновляем существующие записи (если нужно установить дефолтное значение)
if err := db.Exec(`UPDATE users SET password = '' WHERE password IS NULL`).Error; err != nil {
return err
}
// И только потом меняем на NOT NULL
if err := db.Exec(`ALTER TABLE users ALTER COLUMN password SET NOT NULL`).Error; err != nil {
return err
}
// Или используйте автоматические миграции GORM
return db.AutoMigrate(
&models.User{},
&models.OAuthProvider{},
// другие модели...
)
}