commit b6e74bd02485afe0e97e6115776413fa7e87f126 Author: Beyhan Oğur Date: Sun Apr 26 21:41:46 2026 +0300 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ed479c5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env b/.env new file mode 100644 index 0000000..0fe3f60 --- /dev/null +++ b/.env @@ -0,0 +1,47 @@ +# Database Settings (Mysql) +DB_HOST=212.64.215.243 +DB_PORT=3306 +DB_USER=gov3 +DB_PASSWORD=gg7678290 +DB_NAME=gov3 +# JWT Settings (Jwt) +JWT_SECRET=ares-gov3-k1Obxl3kDRMtZ5cs9lvFTh73r5WjfF32ZhakPG6fBDYQmPvzkwsK2rHlaaP2YDmy +JWT_REFRESH_SECRET=ares-gov3-VUCRBBPbkg2lVVhDdzSHGdAXzkThPlD2Ri8LDJEomu1kXUR58ZE1KHJliaYlxIyx +# Server Settings (Gin) +PORT=8080 +# Email Settings (Mailpit) +EMAIL_HOST=10.80.80.70 +EMAIL_PORT=1025 +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_USE_TLS=false +EMAIL_USE_SSL=false +EMAIL_FROM=noreply@gauth.local +# Social Auth (Google) +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY='915364976256-691m0s87as2r5vdbqr96f6humblseobt.apps.googleusercontent.com' # Your Google Client ID +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET='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' # Your Google Client Secret +SOCIAL_AUTH_GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback +# Social Auth (GitHub) +SOCIAL_AUTH_GITHUB_KEY='Ov23liUt9B61O46Mdfm4' # Your GitHub Client ID +SOCIAL_AUTH_GITHUB_SECRET='c7fc8dcb1b2c8f22120608425d07d5efd995baaf' # Your GitHub Client Secret +SOCIAL_AUTH_GITHUB_REDIRECT_URL=http://localhost:8080/auth/github/callback + +# CORS bootstrap seeds (comma separated origins) +# Example: CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,https://admin.example.com +CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,http://localhost:5173,https://admin.goares.com +# Example: CORS_BOOTSTRAP_BLACKLIST_ORIGINS=https://bad.example.com,https://spam.example.com +CORS_BOOTSTRAP_BLACKLIST_ORIGINS=https://spam.goares.com,https://blocked-client.example + +# Rate-limit bootstrap seeds +RL_BOOTSTRAP_LOGIN_MAX_REQUESTS=10 +RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS=60 +RL_BOOTSTRAP_REGISTER_MAX_REQUESTS=5 +RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS=60 +RL_BOOTSTRAP_API_MAX_REQUESTS=120 +RL_BOOTSTRAP_API_WINDOW_SECONDS=60 + +# Dynamic policy debug logs +# true/false +CORS_DEBUG=true +RATE_LIMIT_DEBUG=true + diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/goaresv3.iml b/.idea/goaresv3.iml new file mode 100644 index 0000000..d70b1d2 --- /dev/null +++ b/.idea/goaresv3.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5de3884 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..121f53e --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 7, + "fromUser": false +} + + + + { + "keyToString": { + "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", + "Go Test.go test goaresv3/pkg/jwt.executor": "Run", + "Go Test.go test goaresv3/router.executor": "Run", + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.GoLinterPluginOnboardingV2": "true", + "RunOnceActivity.GoLinterPluginStorageMigration": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.go.analysis.ui.options.defaults": "true", + "RunOnceActivity.go.formatter.settings.were.checked": "true", + "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "SHARE_PROJECT_CONFIGURATION_FILES": "true", + "codeWithMe.voiceChat.enabledByDefault": "false", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", + "git-widget-placeholder": "main", + "go.sdk.automatically.set": "true", + "junie.onboarding.icon.badge.shown": "true", + "last_opened_file_path": "/home/beyhan/Projeler/Go/goaresv3", + "node.js.detected.package.eslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "settings.editor.selected.configurable": "preferences.pluginManager", + "to.speed.mode.migration.done": "true", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1775146460804 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/accounts/controllers/user.go b/app/accounts/controllers/user.go new file mode 100644 index 0000000..a25be0c --- /dev/null +++ b/app/accounts/controllers/user.go @@ -0,0 +1,823 @@ +package controllers + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + githuboauth "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" + "gorm.io/gorm" + + "goaresv3/app/accounts/models" + "goaresv3/config" + jwtHelper "goaresv3/pkg/jwt" + "goaresv3/pkg/mailer" +) + +const googleOAuthStateCookieName = "google_oauth_state" +const githubOAuthStateCookieName = "github_oauth_state" + +type socialUserProfile struct { + ProviderID string + Email string + Name string + AvatarURL string + EmailVerified bool +} + +type googleUserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` +} + +var exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) { + return cfg.Exchange(ctx, code) +} + +var fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) { + client := cfg.Client(ctx, token) + resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo status: %d", resp.StatusCode) + } + + var info googleUserInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + + return &info, nil +} + +type githubUserInfo struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +type githubEmailInfo struct { + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` +} + +var exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) { + return cfg.Exchange(ctx, code) +} + +var fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) { + client := cfg.Client(ctx, token) + + userResp, err := client.Get("https://api.github.com/user") + if err != nil { + return nil, err + } + defer userResp.Body.Close() + if userResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github userinfo status: %d", userResp.StatusCode) + } + + var user githubUserInfo + if err := json.NewDecoder(userResp.Body).Decode(&user); err != nil { + return nil, err + } + + email := strings.TrimSpace(user.Email) + verifiedFromProvider := email != "" + + emailsResp, err := client.Get("https://api.github.com/user/emails") + if err == nil { + defer emailsResp.Body.Close() + if emailsResp.StatusCode == http.StatusOK { + var emails []githubEmailInfo + if err := json.NewDecoder(emailsResp.Body).Decode(&emails); err == nil { + for _, e := range emails { + if e.Primary && e.Verified && strings.TrimSpace(e.Email) != "" { + email = strings.TrimSpace(e.Email) + verifiedFromProvider = true + break + } + } + if email == "" { + for _, e := range emails { + if e.Verified && strings.TrimSpace(e.Email) != "" { + email = strings.TrimSpace(e.Email) + verifiedFromProvider = true + break + } + } + } + } + } + } + + if user.ID == 0 || email == "" { + return nil, fmt.Errorf("github profile is missing required fields") + } + + name := strings.TrimSpace(user.Name) + if name == "" { + name = strings.TrimSpace(user.Login) + } + if name == "" { + parts := strings.Split(email, "@") + if len(parts) > 0 { + name = parts[0] + } + } + + return &socialUserProfile{ + ProviderID: fmt.Sprintf("%d", user.ID), + Email: email, + Name: name, + AvatarURL: strings.TrimSpace(user.AvatarURL), + EmailVerified: verifiedFromProvider, + }, nil +} + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization + +// ── Request DTOs ───────────────────────────────────────────────────────────── + +type RegisterRequest struct { + UserName string `json:"username" binding:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + ConfirmPassword string `json:"confirm_password" binding:"required,min=8"` +} + +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +func generateEmailVerifyToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func generateOAuthStateToken() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func envValue(key string) string { + v := strings.TrimSpace(os.Getenv(key)) + return strings.Trim(v, "'\"") +} + +func fallbackRedirectURL(path string) string { + baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/") + if baseURL == "" { + baseURL = "http://localhost:8080" + } + return baseURL + path +} + +func getGoogleOAuthConfig() (*oauth2.Config, error) { + clientID := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") + clientSecret := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") + redirectURL := envValue("SOCIAL_AUTH_GOOGLE_REDIRECT_URL") + if redirectURL == "" { + redirectURL = fallbackRedirectURL("/api/v1/auth/google/callback") + } + + if clientID == "" || clientSecret == "" || redirectURL == "" { + return nil, fmt.Errorf("google oauth configuration is incomplete") + } + + scopes := []string{"openid", "email", "profile"} + if rawScopes := envValue("SOCIAL_AUTH_GOOGLE_SCOPES"); rawScopes != "" { + scopes = scopes[:0] + for _, scope := range strings.Split(rawScopes, ",") { + s := strings.TrimSpace(scope) + if s != "" { + scopes = append(scopes, s) + } + } + if len(scopes) == 0 { + scopes = []string{"openid", "email", "profile"} + } + } + + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: google.Endpoint, + }, nil +} + +func getGitHubOAuthConfig() (*oauth2.Config, error) { + clientID := envValue("SOCIAL_AUTH_GITHUB_KEY") + clientSecret := envValue("SOCIAL_AUTH_GITHUB_SECRET") + redirectURL := envValue("SOCIAL_AUTH_GITHUB_REDIRECT_URL") + if redirectURL == "" { + redirectURL = fallbackRedirectURL("/api/v1/auth/github/callback") + } + + if clientID == "" || clientSecret == "" || redirectURL == "" { + return nil, fmt.Errorf("github oauth configuration is incomplete") + } + + scopes := []string{"read:user", "user:email"} + if rawScopes := envValue("SOCIAL_AUTH_GITHUB_SCOPES"); rawScopes != "" { + scopes = scopes[:0] + for _, scope := range strings.Split(rawScopes, ",") { + s := strings.TrimSpace(scope) + if s != "" { + scopes = append(scopes, s) + } + } + if len(scopes) == 0 { + scopes = []string{"read:user", "user:email"} + } + } + + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: githuboauth.Endpoint, + }, nil +} + +func resolveUserNameFromGoogleProfile(profile *googleUserInfo) string { + if profile == nil { + return "" + } + if profile.Name != "" { + return profile.Name + } + if profile.GivenName != "" { + return profile.GivenName + } + if profile.Email != "" { + parts := strings.Split(profile.Email, "@") + if len(parts) > 0 { + return parts[0] + } + } + return "google-user" +} + +func completeSocialLogin(c *gin.Context, provider string, profile *socialUserProfile) { + if profile == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing"}) + return + } + if profile.ProviderID == "" || profile.Email == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing required fields"}) + return + } + if !profile.EmailVerified { + c.JSON(http.StatusForbidden, gin.H{"error": "provider email is not verified"}) + return + } + + var user models.User + err := config.DB.Transaction(func(tx *gorm.DB) error { + var social models.SocialAccount + err := tx.Where("provider = ? AND provider_id = ?", provider, profile.ProviderID).First(&social).Error + if err == nil { + if err := tx.First(&user, social.UserID).Error; err != nil { + return err + } + } else if err == gorm.ErrRecordNotFound { + err = tx.Where("email = ?", profile.Email).First(&user).Error + if err == gorm.ErrRecordNotFound { + verified := true + user = models.User{ + UserName: profile.Name, + Email: profile.Email, + EmailVerified: &verified, + EmailVerifiedAt: ptrTime(time.Now()), + } + if err := tx.Create(&user).Error; err != nil { + return err + } + } else if err != nil { + return err + } + + social = models.SocialAccount{ + UserID: uint64(user.ID), + Provider: provider, + ProviderID: profile.ProviderID, + Email: profile.Email, + Name: profile.Name, + AvatarURL: profile.AvatarURL, + } + if err := tx.Create(&social).Error; err != nil { + return err + } + } else { + return err + } + + if !user.IsEmailVerified() { + verified := true + now := time.Now() + if err := tx.Model(&user).Updates(map[string]any{ + "email_verified": &verified, + "email_verified_at": &now, + "email_verify_token": "", + }).Error; err != nil { + return err + } + user.EmailVerified = &verified + user.EmailVerifiedAt = &now + user.EmailVerifyToken = "" + } + + return tx.Model(&models.SocialAccount{}). + Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, provider, profile.ProviderID). + Updates(map[string]any{ + "email": profile.Email, + "name": profile.Name, + "avatar_url": profile.AvatarURL, + }).Error + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process social login"}) + return + } + + accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"}) + return + } + + refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "refresh_token": refreshToken, + "token_type": "Bearer", + "provider": provider, + }) +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +// Register godoc +// @Summary Register a new user +// @Description Creates a user and sends an email verification link. +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "register payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/auth/register [post] +func Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Password != req.ConfirmPassword { + c.JSON(http.StatusBadRequest, gin.H{"error": "password and confirm_password do not match"}) + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not process password"}) + return + } + + verifyToken, err := generateEmailVerifyToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate verification token"}) + return + } + + verified := false + + user := models.User{ + UserName: req.UserName, + Email: req.Email, + Password: string(hashed), + EmailVerified: &verified, + EmailVerifyToken: verifyToken, + } + + if result := config.DB.Create(&user); result.Error != nil { + c.JSON(http.StatusConflict, gin.H{"error": "email already in use"}) + return + } + + baseURL := strings.TrimRight(os.Getenv("APP_BASE_URL"), "/") + if baseURL == "" { + baseURL = "http://localhost:8080" + } + verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", baseURL, verifyToken) + + body := fmt.Sprintf("Hello %s,\n\nPlease verify your email by clicking the link below:\n%s\n\nIf you did not create this account, you can ignore this email.", user.UserName, verifyURL) + if err := mailer.Send(user.Email, "Verify your email", body); err != nil { + _ = config.DB.Delete(&user).Error + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send verification email"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "user created. please verify your email", + "user_id": user.ID, + }) +} + +// VerifyEmail godoc +// @Summary Verify email address +// @Description Activates account using email verification token. +// @Tags Auth +// @Produce json +// @Param token query string true "email verification token" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/auth/verify-email [get] +func VerifyEmail(c *gin.Context) { + token := strings.TrimSpace(c.Query("token")) + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "verification token is required"}) + return + } + + now := time.Now() + verified := true + + result := config.DB.Model(&models.User{}). + Where("email_verify_token = ?", token). + Updates(map[string]interface{}{ + "email_verified": &verified, + "email_verify_token": "", + "email_verified_at": &now, + }) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not verify email"}) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired verification token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "email verified successfully"}) +} + +// Login godoc +// @Summary Login with email/password +// @Description Returns access and refresh tokens. +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "login payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/login [post] +func Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if result := config.DB.Where("email = ?", req.Email).First(&user); result.Error != nil { + // Return generic message to avoid user enumeration + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"}) + return + } + + if !user.IsEmailVerified() { + c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"}) + return + } + + accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"}) + return + } + + refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "refresh_token": refreshToken, + "token_type": "Bearer", + }) +} + +// GoogleLogin godoc +// @Summary Start Google OAuth login +// @Description Returns Google authorization URL and sets state cookie for CSRF protection. +// @Tags Auth +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 503 {object} map[string]string +// @Router /api/v1/auth/google/login [get] +func GoogleLogin(c *gin.Context) { + cfg, err := getGoogleOAuthConfig() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"}) + return + } + + state, err := generateOAuthStateToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize google login"}) + return + } + + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(googleOAuthStateCookieName, state, 600, "/", "", false, true) + + authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline) + c.JSON(http.StatusOK, gin.H{"auth_url": authURL}) +} + +// GoogleCallback godoc +// @Summary Google OAuth callback +// @Description Exchanges Google code and returns local access/refresh tokens. +// @Tags Auth +// @Produce json +// @Param state query string true "oauth state" +// @Param code query string true "authorization code" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/auth/google/callback [get] +func GoogleCallback(c *gin.Context) { + cfg, err := getGoogleOAuthConfig() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"}) + return + } + + if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "google authorization failed"}) + return + } + + state := strings.TrimSpace(c.Query("state")) + code := strings.TrimSpace(c.Query("code")) + if state == "" || code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"}) + return + } + + storedState, err := c.Cookie(googleOAuthStateCookieName) + if err != nil || storedState == "" || storedState != state { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"}) + return + } + c.SetCookie(googleOAuthStateCookieName, "", -1, "/", "", false, true) + + token, err := exchangeGoogleCode(c.Request.Context(), cfg, code) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange google code"}) + return + } + + profile, err := fetchGoogleUserInfo(c.Request.Context(), cfg, token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch google profile"}) + return + } + + if profile.Sub == "" || profile.Email == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "google profile is missing required fields"}) + return + } + + completeSocialLogin(c, "google", &socialUserProfile{ + ProviderID: profile.Sub, + Email: profile.Email, + Name: resolveUserNameFromGoogleProfile(profile), + AvatarURL: profile.Picture, + EmailVerified: profile.EmailVerified, + }) +} + +// GitHubLogin godoc +// @Summary Start GitHub OAuth login +// @Description Returns GitHub authorization URL and sets state cookie for CSRF protection. +// @Tags Auth +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 503 {object} map[string]string +// @Router /api/v1/auth/github/login [get] +func GitHubLogin(c *gin.Context) { + cfg, err := getGitHubOAuthConfig() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"}) + return + } + + state, err := generateOAuthStateToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize github login"}) + return + } + + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(githubOAuthStateCookieName, state, 600, "/", "", false, true) + + authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline) + c.JSON(http.StatusOK, gin.H{"auth_url": authURL}) +} + +// GitHubCallback godoc +// @Summary GitHub OAuth callback +// @Description Exchanges GitHub code and returns local access/refresh tokens. +// @Tags Auth +// @Produce json +// @Param state query string true "oauth state" +// @Param code query string true "authorization code" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/auth/github/callback [get] +func GitHubCallback(c *gin.Context) { + cfg, err := getGitHubOAuthConfig() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"}) + return + } + + if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "github authorization failed"}) + return + } + + state := strings.TrimSpace(c.Query("state")) + code := strings.TrimSpace(c.Query("code")) + if state == "" || code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"}) + return + } + + storedState, err := c.Cookie(githubOAuthStateCookieName) + if err != nil || storedState == "" || storedState != state { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"}) + return + } + c.SetCookie(githubOAuthStateCookieName, "", -1, "/", "", false, true) + + token, err := exchangeGitHubCode(c.Request.Context(), cfg, code) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange github code"}) + return + } + + profile, err := fetchGitHubUserInfo(c.Request.Context(), cfg, token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch github profile"}) + return + } + + completeSocialLogin(c, "github", profile) +} + +func ptrTime(t time.Time) *time.Time { + return &t +} + +// RefreshToken godoc +// @Summary Refresh access token +// @Description Exchanges a valid refresh token for a new access token. +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body RefreshRequest true "refresh payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/refresh [post] +func RefreshToken(c *gin.Context) { + var req RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + claims, err := jwtHelper.ValidateToken(req.RefreshToken, os.Getenv("JWT_REFRESH_SECRET")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"}) + return + } + + var user models.User + if result := config.DB.Select("email", "user_name", "email_verified").First(&user, claims.UserID); result.Error != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user"}) + return + } + if !user.IsEmailVerified() { + c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"}) + return + } + + userName := claims.UserName + if userName == "" { + if result := config.DB.Select("user_name").First(&user, claims.UserID); result.Error == nil { + userName = user.UserName + } + } + if userName == "" { + userName = user.UserName + } + + accessToken, err := jwtHelper.GenerateAccessToken(claims.UserID, user.Email, userName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "token_type": "Bearer", + }) +} + +// Me godoc +// @Summary Get current user info +// @Description Returns user_id, email and username from the authenticated user. +// @Tags User +// @Security BearerAuth +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Router /api/v1/me [get] +func Me(c *gin.Context) { + userName := c.GetString("username") + if userName == "" { + var user models.User + if result := config.DB.Select("user_name").First(&user, c.GetUint("user_id")); result.Error == nil { + userName = user.UserName + } + } + + c.JSON(http.StatusOK, gin.H{ + "user_id": c.GetUint("user_id"), + "email": c.GetString("email"), + "username": userName, + }) +} diff --git a/app/accounts/controllers/user_test.go b/app/accounts/controllers/user_test.go new file mode 100644 index 0000000..a3472af --- /dev/null +++ b/app/accounts/controllers/user_test.go @@ -0,0 +1,652 @@ +package controllers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "goaresv3/app/accounts/models" + "goaresv3/config" + jwtHelper "goaresv3/pkg/jwt" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.User{}); err != nil { + t.Fatalf("failed to migrate user model: %v", err) + } + if err := db.AutoMigrate(&models.SocialAccount{}); err != nil { + t.Fatalf("failed to migrate social account model: %v", err) + } + config.DB = db + return db +} + +func boolPtr(v bool) *bool { + return &v +} + +func TestVerifyEmailSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + + user := models.User{UserName: "u1", Email: "u1@example.com", EmailVerified: boolPtr(false), EmailVerifyToken: "tok-123"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + r := gin.New() + r.GET("/api/v1/auth/verify-email", VerifyEmail) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=tok-123", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var updated models.User + if err := db.First(&updated, user.ID).Error; err != nil { + t.Fatalf("failed to fetch updated user: %v", err) + } + if !updated.IsEmailVerified() { + t.Fatal("expected email_verified=true") + } + if updated.EmailVerifyToken != "" { + t.Fatalf("expected email_verify_token cleared, got %q", updated.EmailVerifyToken) + } +} + +func TestVerifyEmailMissingToken(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + + r := gin.New() + r.GET("/api/v1/auth/verify-email", VerifyEmail) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestVerifyEmailInvalidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + + r := gin.New() + r.GET("/api/v1/auth/verify-email", VerifyEmail) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=missing", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestLoginRejectsUnverifiedUser(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user := models.User{UserName: "u2", Email: "u2@example.com", Password: string(hashed), EmailVerified: boolPtr(false)} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + body, _ := json.Marshal(LoginRequest{Email: "u2@example.com", Password: "password123"}) + r := gin.New() + r.POST("/api/v1/auth/login", Login) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestLoginBadRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + + r := gin.New() + r.POST("/api/v1/auth/login", Login) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"email":"bad"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestLoginInvalidCredentials(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user := models.User{UserName: "u2x", Email: "u2x@example.com", Password: string(hashed), EmailVerified: boolPtr(true)} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + body, _ := json.Marshal(LoginRequest{Email: "u2x@example.com", Password: "wrong-pass"}) + r := gin.New() + r.POST("/api/v1/auth/login", Login) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestLoginVerifiedUserSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user := models.User{UserName: "u3", Email: "u3@example.com", Password: string(hashed), EmailVerified: boolPtr(true)} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + body, _ := json.Marshal(LoginRequest{Email: "u3@example.com", Password: "password123"}) + r := gin.New() + r.POST("/api/v1/auth/login", Login) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestRefreshRejectsUnverifiedUser(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + user := models.User{UserName: "u4", Email: "u4@example.com", EmailVerified: boolPtr(false)} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + rt, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName) + if err != nil { + t.Fatalf("failed to generate refresh token: %v", err) + } + + body, _ := json.Marshal(RefreshRequest{RefreshToken: rt}) + r := gin.New() + r.POST("/api/v1/auth/refresh", RefreshToken) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestRefreshBadRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + + r := gin.New() + r.POST("/api/v1/auth/refresh", RefreshToken) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewBufferString(`{"x":1}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestRefreshInvalidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + r := gin.New() + r.POST("/api/v1/auth/refresh", RefreshToken) + + body := []byte(`{"refresh_token":"not-a-jwt"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestRefreshInvalidUser(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + rt, err := jwtHelper.GenerateRefreshToken(9999, "ghost@example.com", "ghost") + if err != nil { + t.Fatalf("failed to generate refresh token: %v", err) + } + + body, _ := json.Marshal(RefreshRequest{RefreshToken: rt}) + r := gin.New() + r.POST("/api/v1/auth/refresh", RefreshToken) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestRegisterPasswordMismatch(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + + r := gin.New() + r.POST("/api/v1/auth/register", Register) + + body := []byte(`{"username":"u","email":"u@example.com","password":"password123","confirm_password":"different123"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestRegisterDuplicateEmail(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + + verified := true + hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + seed := models.User{UserName: "seed", Email: "dup@example.com", Password: string(hashed), EmailVerified: &verified} + if err := db.Create(&seed).Error; err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + r := gin.New() + r.POST("/api/v1/auth/register", Register) + + body := []byte(`{"username":"newuser","email":"dup@example.com","password":"password123","confirm_password":"password123"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +func TestRegisterMailFailureRollsBackUser(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + + t.Setenv("EMAIL_HOST", "") + t.Setenv("EMAIL_PORT", "") + t.Setenv("EMAIL_FROM", "") + + r := gin.New() + r.POST("/api/v1/auth/register", Register) + + body := []byte(`{"username":"rollback","email":"rollback@example.com","password":"password123","confirm_password":"password123"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } + + var count int64 + if err := db.Model(&models.User{}).Where("email = ?", "rollback@example.com").Count(&count).Error; err != nil { + t.Fatalf("failed to count users: %v", err) + } + if count != 0 { + t.Fatalf("expected rollback delete, user count=%d", count) + } +} + +func TestMeIncludesUsernameFallbackFromDB(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + + user := models.User{UserName: "fallback-user", Email: "u5@example.com", EmailVerified: boolPtr(true)} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + c.Set("user_id", user.ID) + c.Set("email", user.Email) + + Me(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["username"] != "fallback-user" { + t.Fatalf("expected username=fallback-user, got %v", resp["username"]) + } +} + +func TestMeUsesContextUsernameWhenProvided(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + c.Set("user_id", uint(99)) + c.Set("email", "ctx@example.com") + c.Set("username", "ctx-user") + + Me(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["username"] != "ctx-user" { + t.Fatalf("expected username=ctx-user, got %v", resp["username"]) + } +} + +func TestGoogleLoginMissingConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "") + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "") + t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "") + + r := gin.New() + r.GET("/api/v1/auth/google/login", GoogleLogin) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/login", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestGoogleCallbackInvalidState(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id") + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret") + t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback") + + r := gin.New() + r.GET("/api/v1/auth/google/callback", GoogleCallback) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state1&code=code1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestGoogleCallbackSuccessCreatesUserAndTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id") + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret") + t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback") + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + originalExchange := exchangeGoogleCode + originalFetch := fetchGoogleUserInfo + t.Cleanup(func() { + exchangeGoogleCode = originalExchange + fetchGoogleUserInfo = originalFetch + }) + + exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "google-access", TokenType: "Bearer"}, nil + } + fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) { + return &googleUserInfo{ + Sub: "google-sub-123", + Email: "google.user@example.com", + EmailVerified: true, + Name: "Google User", + Picture: "https://cdn.example.com/avatar.png", + GivenName: "Google", + FamilyName: "User", + }, nil + } + + r := gin.New() + r.GET("/api/v1/auth/google/callback", GoogleCallback) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state123&code=code123", nil) + req.AddCookie(&http.Cookie{Name: googleOAuthStateCookieName, Value: "state123"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["access_token"] == "" || resp["refresh_token"] == "" { + t.Fatal("expected access_token and refresh_token in response") + } + if resp["provider"] != "google" { + t.Fatalf("expected provider=google, got %v", resp["provider"]) + } + + var user models.User + if err := db.Where("email = ?", "google.user@example.com").First(&user).Error; err != nil { + t.Fatalf("expected user to be created, err=%v", err) + } + if !user.IsEmailVerified() { + t.Fatal("expected created google user to be verified") + } + + var social models.SocialAccount + if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "google", "google-sub-123").First(&social).Error; err != nil { + t.Fatalf("expected social account to be linked, err=%v", err) + } +} + +func TestGitHubLoginMissingConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + setupTestDB(t) + t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "") + t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "") + t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "") + + r := gin.New() + r.GET("/api/v1/auth/github/login", GitHubLogin) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/login", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestGitHubCallbackSuccessCreatesVerifiedUserAndTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "gh-client-id") + t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "gh-client-secret") + t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "http://localhost:8080/api/v1/auth/github/callback") + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + originalExchange := exchangeGitHubCode + originalFetch := fetchGitHubUserInfo + t.Cleanup(func() { + exchangeGitHubCode = originalExchange + fetchGitHubUserInfo = originalFetch + }) + + exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "github-access", TokenType: "Bearer"}, nil + } + fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) { + return &socialUserProfile{ + ProviderID: "4242", + Email: "github.user@example.com", + Name: "GitHub User", + AvatarURL: "https://cdn.example.com/gh-avatar.png", + EmailVerified: true, + }, nil + } + + r := gin.New() + r.GET("/api/v1/auth/github/callback", GitHubCallback) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/callback?state=state-gh-123&code=code-gh-123", nil) + req.AddCookie(&http.Cookie{Name: githubOAuthStateCookieName, Value: "state-gh-123"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["access_token"] == "" || resp["refresh_token"] == "" { + t.Fatal("expected access_token and refresh_token in response") + } + if resp["provider"] != "github" { + t.Fatalf("expected provider=github, got %v", resp["provider"]) + } + + var user models.User + if err := db.Where("email = ?", "github.user@example.com").First(&user).Error; err != nil { + t.Fatalf("expected user to be created, err=%v", err) + } + if !user.IsEmailVerified() { + t.Fatal("expected created github user to be verified") + } + + var social models.SocialAccount + if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "github", "4242").First(&social).Error; err != nil { + t.Fatalf("expected github social account to be linked, err=%v", err) + } +} + +func TestGoogleOAuthConfigSupportsSocialAuthEnv(t *testing.T) { + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "'google-client-id-from-social'") + t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "'google-client-secret-from-social'") + t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "") + t.Setenv("APP_BASE_URL", "http://localhost:8080") + + cfg, err := getGoogleOAuthConfig() + if err != nil { + t.Fatalf("expected config, got error: %v", err) + } + if cfg.ClientID != "google-client-id-from-social" { + t.Fatalf("unexpected client id: %q", cfg.ClientID) + } + if cfg.ClientSecret != "google-client-secret-from-social" { + t.Fatalf("unexpected client secret: %q", cfg.ClientSecret) + } + if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/google/callback" { + t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL) + } +} + +func TestGitHubOAuthConfigSupportsSocialAuthEnv(t *testing.T) { + t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "'github-client-id-from-social'") + t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "'github-client-secret-from-social'") + t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "") + t.Setenv("APP_BASE_URL", "http://localhost:8080") + + cfg, err := getGitHubOAuthConfig() + if err != nil { + t.Fatalf("expected config, got error: %v", err) + } + if cfg.ClientID != "github-client-id-from-social" { + t.Fatalf("unexpected client id: %q", cfg.ClientID) + } + if cfg.ClientSecret != "github-client-secret-from-social" { + t.Fatalf("unexpected client secret: %q", cfg.ClientSecret) + } + if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/github/callback" { + t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL) + } +} diff --git a/app/accounts/models/accounts.go b/app/accounts/models/accounts.go new file mode 100644 index 0000000..83202b4 --- /dev/null +++ b/app/accounts/models/accounts.go @@ -0,0 +1,48 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + gorm.Model + UserName string `json:"username" gorm:"type:varchar(255)"` + Email string `gorm:"uniqueIndex;not null;type:varchar(255)" json:"email"` + Password string `json:"-" gorm:"type:varchar(255)"` // Password shouldn't be returned in JSON + EmailVerified *bool `gorm:"default:false" json:"email_verified"` // default false for email/password registration + EmailVerifyToken string `gorm:"index;type:varchar(255)" json:"-"` + EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"` + IsAdmin *bool `gorm:"default:false" json:"is_admin"` + SocialAccounts []SocialAccount `gorm:"foreignKey:UserID" json:"social_accounts,omitempty"` + Profile []Profile `gorm:"foreignKey:UserID" json:"profiles,omitempty"` +} + +// Email Veriyf i False Döndürüyor +func (u *User) IsEmailVerified() bool { + if u.EmailVerified == nil { + return false + } + return *u.EmailVerified +} + +// SocialAccount model structure +type SocialAccount struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"` + Provider string `gorm:"type:varchar(32);not null;uniqueIndex:idx_social_provider_identity" json:"provider"` // google, github + ProviderID string `gorm:"type:varchar(191);not null;uniqueIndex:idx_social_provider_identity" json:"provider_id"` + Email string `json:"email" gorm:"type:varchar(255)"` + Name string `json:"name,omitempty" gorm:"type:varchar(255)"` // Full name from provider + AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider + +} +type Profile struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"` + AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider + FirstName string `json:"first_name" gorm:"type:varchar(255)"` // Full name from provider + LastName string `json:"last_name" gorm:"type:varchar(255)"` // Full name from provider + +} diff --git a/app/accounts/models/token.go b/app/accounts/models/token.go new file mode 100644 index 0000000..20caacb --- /dev/null +++ b/app/accounts/models/token.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// RefreshToken represents a server-side record of issued refresh tokens +// to support rotation, revocation and reuse detection. +type RefreshToken struct { + gorm.Model + UserID uint `gorm:"not null;index" json:"user_id"` + TokenID string `gorm:"type:varchar(128);not null;uniqueIndex" json:"token_id"` + // TokenHash is SHA-256 hex of the refresh token string (64 chars). + // Stored instead of the raw token for security, while still allowing debug/lookup. + TokenHash string `gorm:"type:char(64);index" json:"token_hash"` + // TokenFingerprint is a masked representation (e.g. first6...last4) to help operators + // visually correlate DB rows with logs without storing full token. + TokenFingerprint string `gorm:"type:varchar(32);index" json:"token_fingerprint"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` + Revoked bool `gorm:"index" json:"revoked"` + ReplacedByTokenID string `gorm:"type:varchar(128)" json:"replaced_by_token_id"` + UserAgent string `gorm:"type:varchar(255)" json:"user_agent"` + IP string `gorm:"type:varchar(64)" json:"ip"` +} + diff --git a/app/blog/controllers/blog.go b/app/blog/controllers/blog.go new file mode 100644 index 0000000..d89cb51 --- /dev/null +++ b/app/blog/controllers/blog.go @@ -0,0 +1,425 @@ +package controllers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + blogModels "goaresv3/app/blog/models" + "goaresv3/config" +) + +type UpsertCategoryRequest struct { + Title string `json:"title" binding:"required,max=254"` + Slug string `json:"slug" binding:"required,max=254"` + Description string `json:"description"` + ParentID *uint `json:"parent_id"` +} + +type UpsertTagRequest struct { + Name string `json:"name" binding:"required,max=254"` +} + +type UpsertPostRequest struct { + Title string `json:"title" binding:"required,max=254"` + Images string `json:"images" binding:"required"` + ImagesMid string `json:"images_mid" binding:"required"` + ImagesMin string `json:"images_min" binding:"required"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Format string `json:"format" binding:"omitempty,max=10"` + Content string `json:"content"` + Slug string `json:"slug" binding:"required,max=254"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` +} + +func parseBlogID(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return 0, false + } + return uint(id), true +} + +// ListCategories godoc +// @Summary List blog categories +// @Tags Blog +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/blog/categories [get] +func ListCategories(c *gin.Context) { + var items []blogModels.Category + if err := config.DB.Preload("Children").Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch categories"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateCategory godoc +// @Summary Create blog category +// @Tags Blog +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertCategoryRequest true "category payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/blog/categories [post] +func CreateCategory(c *gin.Context) { + var req UpsertCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := blogModels.Category{ + Title: req.Title, + Slug: req.Slug, + Description: req.Description, + ParentID: req.ParentID, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create category"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateCategory godoc +// @Summary Update blog category +// @Tags Blog +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "category id" +// @Param request body UpsertCategoryRequest true "category payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/blog/categories/{id} [put] +func UpdateCategory(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + var req UpsertCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item blogModels.Category + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "title": req.Title, + "slug": req.Slug, + "description": req.Description, + "parent_id": req.ParentID, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update category"}) + return + } + _ = config.DB.First(&item, id).Error + c.JSON(http.StatusOK, item) +} + +// DeleteCategory godoc +// @Summary Delete blog category +// @Tags Blog +// @Security BearerAuth +// @Produce json +// @Param id path int true "category id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/blog/categories/{id} [delete] +func DeleteCategory(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + res := config.DB.Delete(&blogModels.Category{}, id) + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "category deleted"}) +} + +// ListTags godoc +// @Summary List blog tags +// @Tags Blog +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Router /api/v1/blog/tags [get] +func ListTags(c *gin.Context) { + var items []blogModels.Tag + if err := config.DB.Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch tags"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateTag godoc +// @Summary Create blog tag +// @Tags Blog +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertTagRequest true "tag payload" +// @Success 201 {object} map[string]interface{} +// @Router /api/v1/blog/tags [post] +func CreateTag(c *gin.Context) { + var req UpsertTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := blogModels.Tag{Name: req.Name} + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create tag"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateTag godoc +// @Summary Update blog tag +// @Tags Blog +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "tag id" +// @Param request body UpsertTagRequest true "tag payload" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/blog/tags/{id} [put] +func UpdateTag(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + var req UpsertTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item blogModels.Tag + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + if err := config.DB.Model(&item).Update("name", req.Name).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update tag"}) + return + } + _ = config.DB.First(&item, id).Error + c.JSON(http.StatusOK, item) +} + +// DeleteTag godoc +// @Summary Delete blog tag +// @Tags Blog +// @Security BearerAuth +// @Produce json +// @Param id path int true "tag id" +// @Success 200 {object} map[string]string +// @Router /api/v1/blog/tags/{id} [delete] +func DeleteTag(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + res := config.DB.Delete(&blogModels.Tag{}, id) + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "tag deleted"}) +} + +// ListPosts godoc +// @Summary List blog posts +// @Tags Blog +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Router /api/v1/blog/posts [get] +func ListPosts(c *gin.Context) { + var items []blogModels.Post + if err := config.DB.Preload("Categories").Preload("Tags").Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch posts"}) + return + } + c.JSON(http.StatusOK, items) +} + +// GetPost godoc +// @Summary Get blog post +// @Tags Blog +// @Produce json +// @Param id path int true "post id" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/blog/posts/{id} [get] +func GetPost(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + var item blogModels.Post + if err := config.DB.Preload("Categories").Preload("Tags").First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusOK, item) +} + +// CreatePost godoc +// @Summary Create blog post +// @Tags Blog +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertPostRequest true "post payload" +// @Success 201 {object} map[string]interface{} +// @Router /api/v1/blog/posts [post] +func CreatePost(c *gin.Context) { + var req UpsertPostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := blogModels.Post{ + Title: req.Title, + Images: req.Images, + ImagesMid: req.ImagesMid, + ImagesMin: req.ImagesMin, + Width: req.Width, + Height: req.Height, + Quality: req.Quality, + Format: req.Format, + Content: req.Content, + Slug: req.Slug, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create post"}) + return + } + if err := assignPostRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign relations"}) + return + } + _ = config.DB.Preload("Categories").Preload("Tags").First(&item, item.ID).Error + c.JSON(http.StatusCreated, item) +} + +// UpdatePost godoc +// @Summary Update blog post +// @Tags Blog +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "post id" +// @Param request body UpsertPostRequest true "post payload" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/blog/posts/{id} [put] +func UpdatePost(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + var req UpsertPostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item blogModels.Post + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "title": req.Title, + "images": req.Images, + "images_mid": req.ImagesMid, + "images_min": req.ImagesMin, + "width": req.Width, + "height": req.Height, + "quality": req.Quality, + "format": req.Format, + "content": req.Content, + "slug": req.Slug, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update post"}) + return + } + if err := assignPostRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign relations"}) + return + } + _ = config.DB.Preload("Categories").Preload("Tags").First(&item, item.ID).Error + c.JSON(http.StatusOK, item) +} + +// DeletePost godoc +// @Summary Delete blog post +// @Tags Blog +// @Security BearerAuth +// @Produce json +// @Param id path int true "post id" +// @Success 200 {object} map[string]string +// @Router /api/v1/blog/posts/{id} [delete] +func DeletePost(c *gin.Context) { + id, ok := parseBlogID(c) + if !ok { + return + } + res := config.DB.Delete(&blogModels.Post{}, id) + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "post deleted"}) +} + +func assignPostRelations(postID uint, categoryIDs, tagIDs []uint) error { + var p blogModels.Post + if err := config.DB.First(&p, postID).Error; err != nil { + return err + } + if categoryIDs != nil { + var categories []blogModels.Category + if len(categoryIDs) > 0 { + if err := config.DB.Where("id IN ?", categoryIDs).Find(&categories).Error; err != nil { + return err + } + } + if err := config.DB.Model(&p).Association("Categories").Replace(categories); err != nil { + return err + } + } + if tagIDs != nil { + var tags []blogModels.Tag + if len(tagIDs) > 0 { + if err := config.DB.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil { + return err + } + } + if err := config.DB.Model(&p).Association("Tags").Replace(tags); err != nil { + return err + } + } + return nil +} diff --git a/app/blog/models/blog.go b/app/blog/models/blog.go new file mode 100644 index 0000000..abff7a4 --- /dev/null +++ b/app/blog/models/blog.go @@ -0,0 +1,53 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Minimal, temiz GORM modelleri + +type Category struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"` + Description string `json:"description,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` + Parent *Category `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"` + Children []Category `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Posts []Post `gorm:"many2many:post_categories;" json:"posts,omitempty"` +} + +type Tag struct { + gorm.Model + Name string `gorm:"type:varchar(254);not null" json:"name"` + Posts []Post `gorm:"many2many:post_tags;" json:"posts,omitempty"` +} + +type Post struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"` + Images string `gorm:"type:text;not null" json:"images" form:"images"` + ImagesMid string `gorm:"type:text;not null" json:"images_mid" form:"images_mid"` + ImagesMin string `gorm:"type:text;not null" json:"images_min" form:"images_min"` + Width int `gorm:"default:0" json:"width" form:"width"` + Height int `gorm:"default:0" json:"height" form:"height"` + Quality int `gorm:"default:0" json:"quality" form:"quality"` + Format string `gorm:"type:varchar(10)" json:"format" form:"format" default:"avif"` + Content string `gorm:"type:text" json:"content,omitempty" form:"content"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug" form:"slug"` + Categories []Category `gorm:"many2many:post_categories;" json:"categories,omitempty" form:"categories"` + Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty" form:"tags"` +} + +type CategoryView struct { + gorm.Model + CategoryID uint `json:"category_id"` + IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"` +} + +type Comment struct { + gorm.Model + UserID uint `json:"user_id"` + PostID uint `json:"post_id"` + Body string `gorm:"type:text" json:"body,omitempty"` +} diff --git a/app/settings/controllers/settings.go b/app/settings/controllers/settings.go new file mode 100644 index 0000000..275a78f --- /dev/null +++ b/app/settings/controllers/settings.go @@ -0,0 +1,747 @@ +package controllers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "goaresv3/app/settings/models" + "goaresv3/config" +) + +type UpsertSettingRequest struct { + Title string `json:"title" binding:"required,max=254"` + MetaTitle string `json:"meta_title" binding:"required,max=254"` + MetaDescription string `json:"meta_description" binding:"required,max=254"` + Phone string `json:"phone" binding:"required,max=254"` + URL string `json:"url" binding:"required,max=254"` + Email string `json:"email" binding:"required,email,max=254"` + Facebook string `json:"facebook" binding:"omitempty,max=254"` + X string `json:"x" binding:"omitempty,max=254"` + Instagram string `json:"instagram" binding:"omitempty,max=254"` + Whatsapp string `json:"whatsapp" binding:"omitempty,max=254"` + Pinterest string `json:"pinterest" binding:"omitempty,max=254"` + Linkedin string `json:"linkedin" binding:"omitempty,max=254"` + Slogan string `json:"slogan" binding:"omitempty,max=254"` + Address string `json:"address"` + Copyright string `json:"copyright" binding:"omitempty,max=254"` + MapEmbed string `json:"map_embed"` + WLogo string `json:"w_logo"` + BLogo string `json:"b_logo"` + IsActive bool `json:"is_active"` + WWidth int `json:"w_width"` + WHeight int `json:"w_height"` + WQuality int `json:"w_quality"` + WFormat string `json:"w_format" binding:"omitempty,max=10"` + BWidth int `json:"b_width"` + BHeight int `json:"b_height"` + BQuality int `json:"b_quality"` + BFormat string `json:"b_format" binding:"omitempty,max=10"` +} + +type UpsertHeroRequest struct { + Color string `json:"color" binding:"required,max=32"` + Title string `json:"title" binding:"omitempty,max=254"` + Text1 string `json:"text1" binding:"omitempty,max=254"` + Text2 string `json:"text2" binding:"omitempty,max=254"` + Text4 string `json:"text4" binding:"omitempty,max=254"` + Text5 string `json:"text5" binding:"omitempty,max=254"` + Image string `json:"image" binding:"omitempty,max=254"` + IsActive bool `json:"is_active"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Format string `json:"format" binding:"omitempty,max=10"` +} + +type UpsertCorsWhitelistRequest struct { + Origin string `json:"origin" binding:"required,max=255"` + Description string `json:"description" binding:"omitempty,max=255"` + IsActive bool `json:"is_active"` + CreatedBy string `json:"created_by" binding:"omitempty,max=255"` +} + +type UpsertCorsBlacklistRequest struct { + Origin string `json:"origin" binding:"required,max=255"` + Reason string `json:"reason" binding:"omitempty,max=255"` + IsActive bool `json:"is_active"` + CreatedBy string `json:"created_by" binding:"omitempty,max=255"` +} + +type UpsertRateLimitRequest struct { + Name string `json:"name" binding:"required,max=100"` + Description string `json:"description" binding:"omitempty,max=255"` + MaxRequests int64 `json:"max_requests" binding:"required,min=1"` + WindowSeconds int `json:"window_seconds" binding:"required,min=1"` + IsActive bool `json:"is_active"` + UpdatedBy string `json:"updated_by" binding:"omitempty,max=255"` +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return 0, false + } + return id, true +} + +// GetSetting godoc +// @Summary Get global setting +// @Description Returns the latest settings record. +// @Tags Settings +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings [get] +func GetSetting(c *gin.Context) { + var setting models.Setting + err := config.DB.Order("id DESC").First(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch setting"}) + return + } + c.JSON(http.StatusOK, setting) +} + +// UpsertSetting godoc +// @Summary Create or update global setting +// @Description Creates the first setting record if none exists, otherwise updates the latest one. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertSettingRequest true "setting payload" +// @Success 200 {object} map[string]interface{} +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings [put] +func UpsertSetting(c *gin.Context) { + var req UpsertSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var setting models.Setting + err := config.DB.Order("id DESC").First(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + setting = models.Setting{ + Title: req.Title, + MetaTitle: req.MetaTitle, + MetaDescription: req.MetaDescription, + Phone: req.Phone, + URL: req.URL, + Email: req.Email, + Facebook: req.Facebook, + X: req.X, + Instagram: req.Instagram, + Whatsapp: req.Whatsapp, + Pinterest: req.Pinterest, + Linkedin: req.Linkedin, + Slogan: req.Slogan, + Address: req.Address, + Copyright: req.Copyright, + MapEmbed: req.MapEmbed, + WLogo: req.WLogo, + BLogo: req.BLogo, + IsActive: req.IsActive, + WWidth: req.WWidth, + WHeight: req.WHeight, + WQuality: req.WQuality, + WFormat: req.WFormat, + BWidth: req.BWidth, + BHeight: req.BHeight, + BQuality: req.BQuality, + BFormat: req.BFormat, + } + if createErr := config.DB.Create(&setting).Error; createErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create setting"}) + return + } + c.JSON(http.StatusCreated, setting) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch setting"}) + return + } + + updates := map[string]any{ + "title": req.Title, + "meta_title": req.MetaTitle, + "meta_description": req.MetaDescription, + "phone": req.Phone, + "url": req.URL, + "email": req.Email, + "facebook": req.Facebook, + "x": req.X, + "instagram": req.Instagram, + "whatsapp": req.Whatsapp, + "pinterest": req.Pinterest, + "linkedin": req.Linkedin, + "slogan": req.Slogan, + "address": req.Address, + "copyright": req.Copyright, + "map_embed": req.MapEmbed, + "w_logo": req.WLogo, + "b_logo": req.BLogo, + "is_active": req.IsActive, + "w_width": req.WWidth, + "w_height": req.WHeight, + "w_quality": req.WQuality, + "w_format": req.WFormat, + "b_width": req.BWidth, + "b_height": req.BHeight, + "b_quality": req.BQuality, + "b_format": req.BFormat, + } + if err := config.DB.Model(&setting).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"}) + return + } + + if err := config.DB.First(&setting, setting.ID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated setting"}) + return + } + c.JSON(http.StatusOK, setting) +} + +// ListHeroes godoc +// @Summary List heroes +// @Description Returns all hero records ordered by id desc. +// @Tags Settings +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/heroes [get] +func ListHeroes(c *gin.Context) { + var heroes []models.Hero + if err := config.DB.Order("id DESC").Find(&heroes).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch heroes"}) + return + } + c.JSON(http.StatusOK, heroes) +} + +// CreateHero godoc +// @Summary Create hero +// @Description Creates a new hero record. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertHeroRequest true "hero payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/heroes [post] +func CreateHero(c *gin.Context) { + var req UpsertHeroRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hero := models.Hero{ + Color: req.Color, + Title: req.Title, + Text1: req.Text1, + Text2: req.Text2, + Text4: req.Text4, + Text5: req.Text5, + Image: req.Image, + IsActive: req.IsActive, + Width: req.Width, + Height: req.Height, + Quality: req.Quality, + Format: req.Format, + } + if err := config.DB.Create(&hero).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create hero"}) + return + } + c.JSON(http.StatusCreated, hero) +} + +// UpdateHero godoc +// @Summary Update hero +// @Description Updates a hero by id. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "hero id" +// @Param request body UpsertHeroRequest true "hero payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/heroes/{id} [put] +func UpdateHero(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req UpsertHeroRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var hero models.Hero + if err := config.DB.First(&hero, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch hero"}) + return + } + + if err := config.DB.Model(&hero).Updates(map[string]any{ + "color": req.Color, + "title": req.Title, + "text1": req.Text1, + "text2": req.Text2, + "text4": req.Text4, + "text5": req.Text5, + "image": req.Image, + "is_active": req.IsActive, + "width": req.Width, + "height": req.Height, + "quality": req.Quality, + "format": req.Format, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update hero"}) + return + } + + if err := config.DB.First(&hero, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated hero"}) + return + } + c.JSON(http.StatusOK, hero) +} + +// DeleteHero godoc +// @Summary Delete hero +// @Description Deletes a hero by id. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "hero id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/heroes/{id} [delete] +func DeleteHero(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + res := config.DB.Delete(&models.Hero{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete hero"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "hero deleted"}) +} + +// ListCorsWhitelists godoc +// @Summary List CORS whitelist items +// @Description Returns all CORS whitelist records. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/cors/whitelist [get] +func ListCorsWhitelists(c *gin.Context) { + var items []models.CorsWhitelist + if err := config.DB.Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors whitelists"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateCorsWhitelist godoc +// @Summary Create CORS whitelist item +// @Description Creates a new whitelist origin. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertCorsWhitelistRequest true "cors whitelist payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/settings/cors/whitelist [post] +func CreateCorsWhitelist(c *gin.Context) { + var req UpsertCorsWhitelistRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := models.CorsWhitelist{ + Origin: req.Origin, + Description: req.Description, + IsActive: req.IsActive, + CreatedBy: req.CreatedBy, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create cors whitelist"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateCorsWhitelist godoc +// @Summary Update CORS whitelist item +// @Description Updates a whitelist origin by id. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "whitelist id" +// @Param request body UpsertCorsWhitelistRequest true "cors whitelist payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/settings/cors/whitelist/{id} [put] +func UpdateCorsWhitelist(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpsertCorsWhitelistRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item models.CorsWhitelist + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "cors whitelist not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors whitelist"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "origin": req.Origin, + "description": req.Description, + "is_active": req.IsActive, + "created_by": req.CreatedBy, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update cors whitelist"}) + return + } + if err := config.DB.First(&item, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated cors whitelist"}) + return + } + c.JSON(http.StatusOK, item) +} + +// DeleteCorsWhitelist godoc +// @Summary Delete CORS whitelist item +// @Description Deletes a whitelist origin by id. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "whitelist id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/cors/whitelist/{id} [delete] +func DeleteCorsWhitelist(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + res := config.DB.Delete(&models.CorsWhitelist{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete cors whitelist"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "cors whitelist not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "cors whitelist deleted"}) +} + +// ListCorsBlacklists godoc +// @Summary List CORS blacklist items +// @Description Returns all CORS blacklist records. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/cors/blacklist [get] +func ListCorsBlacklists(c *gin.Context) { + var items []models.CorsBlacklist + if err := config.DB.Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors blacklists"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateCorsBlacklist godoc +// @Summary Create CORS blacklist item +// @Description Creates a new blacklist origin. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertCorsBlacklistRequest true "cors blacklist payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/settings/cors/blacklist [post] +func CreateCorsBlacklist(c *gin.Context) { + var req UpsertCorsBlacklistRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := models.CorsBlacklist{ + Origin: req.Origin, + Reason: req.Reason, + IsActive: req.IsActive, + CreatedBy: req.CreatedBy, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create cors blacklist"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateCorsBlacklist godoc +// @Summary Update CORS blacklist item +// @Description Updates a blacklist origin by id. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "blacklist id" +// @Param request body UpsertCorsBlacklistRequest true "cors blacklist payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/settings/cors/blacklist/{id} [put] +func UpdateCorsBlacklist(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpsertCorsBlacklistRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item models.CorsBlacklist + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "cors blacklist not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors blacklist"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "origin": req.Origin, + "reason": req.Reason, + "is_active": req.IsActive, + "created_by": req.CreatedBy, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update cors blacklist"}) + return + } + if err := config.DB.First(&item, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated cors blacklist"}) + return + } + c.JSON(http.StatusOK, item) +} + +// DeleteCorsBlacklist godoc +// @Summary Delete CORS blacklist item +// @Description Deletes a blacklist origin by id. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "blacklist id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/cors/blacklist/{id} [delete] +func DeleteCorsBlacklist(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + res := config.DB.Delete(&models.CorsBlacklist{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete cors blacklist"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "cors blacklist not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "cors blacklist deleted"}) +} + +// ListRateLimits godoc +// @Summary List rate limits +// @Description Returns all rate-limit settings. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/rate-limits [get] +func ListRateLimits(c *gin.Context) { + var items []models.RateLimitSetting + if err := config.DB.Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch rate limits"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateRateLimit godoc +// @Summary Create rate limit +// @Description Creates a new rate-limit setting. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertRateLimitRequest true "rate limit payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/settings/rate-limits [post] +func CreateRateLimit(c *gin.Context) { + var req UpsertRateLimitRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := models.RateLimitSetting{ + Name: req.Name, + Description: req.Description, + MaxRequests: req.MaxRequests, + WindowSeconds: req.WindowSeconds, + IsActive: req.IsActive, + UpdatedBy: req.UpdatedBy, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create rate limit"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateRateLimit godoc +// @Summary Update rate limit +// @Description Updates a rate-limit setting by id. +// @Tags Settings +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "rate limit id" +// @Param request body UpsertRateLimitRequest true "rate limit payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/settings/rate-limits/{id} [put] +func UpdateRateLimit(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpsertRateLimitRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item models.RateLimitSetting + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "rate limit not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch rate limit"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "name": req.Name, + "description": req.Description, + "max_requests": req.MaxRequests, + "window_seconds": req.WindowSeconds, + "is_active": req.IsActive, + "updated_by": req.UpdatedBy, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update rate limit"}) + return + } + if err := config.DB.First(&item, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated rate limit"}) + return + } + c.JSON(http.StatusOK, item) +} + +// DeleteRateLimit godoc +// @Summary Delete rate limit +// @Description Deletes a rate-limit setting by id. +// @Tags Settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "rate limit id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings/rate-limits/{id} [delete] +func DeleteRateLimit(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + res := config.DB.Delete(&models.RateLimitSetting{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete rate limit"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "rate limit not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "rate limit deleted"}) +} diff --git a/app/settings/models/cors.go b/app/settings/models/cors.go new file mode 100644 index 0000000..d3faaf0 --- /dev/null +++ b/app/settings/models/cors.go @@ -0,0 +1,34 @@ +package models + +import ( + "gorm.io/gorm" +) + +// CorsWhitelist - CORS için izin verilen origin'ler +type CorsWhitelist struct { + gorm.Model + Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"` + Description string `gorm:"type:varchar(255)" json:"description"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"` +} + +// CorsBlacklist - CORS için yasaklanan origin'ler +type CorsBlacklist struct { + gorm.Model + Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"` + Reason string `gorm:"type:varchar(255)" json:"reason"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"` +} + +// RateLimitSetting - Rate limit ayarları +type RateLimitSetting struct { + gorm.Model + Name string `gorm:"type:varchar(100);uniqueIndex;not null" json:"name"` // e.g., "login", "register", "api" + Description string `gorm:"type:varchar(255)" json:"description"` + MaxRequests int64 `gorm:"not null" json:"max_requests"` // Max istek sayısı + WindowSeconds int `gorm:"not null" json:"window_seconds"` // Zaman penceresi (saniye) + IsActive bool `gorm:"default:true" json:"is_active"` + UpdatedBy string `gorm:"type:varchar(255)" json:"updated_by,omitempty"` +} diff --git a/app/settings/models/hero.go b/app/settings/models/hero.go new file mode 100644 index 0000000..314a3df --- /dev/null +++ b/app/settings/models/hero.go @@ -0,0 +1,23 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Banner model structure +// Represents a banner item with optional thumbnail. +type Hero struct { + gorm.Model + Color string `gorm:"type:varchar(32);not null" json:"color" form:"color"` + Title string `gorm:"type:varchar(254)" json:"title,omitempty" form:"title"` + Text1 string `gorm:"type:varchar(254)" json:"text1,omitempty" form:"text1"` + Text2 string `gorm:"type:varchar(254)" json:"text2,omitempty" form:"text2"` + Text4 string `gorm:"type:varchar(254)" json:"text4,omitempty" form:"text4"` + Text5 string `gorm:"type:varchar(254)" json:"text5,omitempty" form:"text5"` + Image string `gorm:"type:varchar(254)" json:"image" form:"image"` + IsActive bool `gorm:"default:true" json:"is_active" form:"is_active"` + Width int `gorm:"default:0" json:"width" form:"width"` + Height int `gorm:"default:0" json:"height" form:"height"` + Quality int `gorm:"default:0" json:"quality" form:"quality"` + Format string `gorm:"type:varchar(10)" json:"format" form:"format"` +} diff --git a/app/settings/models/setting.go b/app/settings/models/setting.go new file mode 100644 index 0000000..cfcd73e --- /dev/null +++ b/app/settings/models/setting.go @@ -0,0 +1,43 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Setting model structure +// Stores site-wide metadata and contact information. +type Setting struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"` + MetaTitle string `gorm:"type:varchar(254);not null" json:"meta_title" form:"meta_title"` + MetaDescription string `gorm:"type:varchar(254);not null" json:"meta_description" form:"meta_description"` + Phone string `gorm:"type:varchar(254);not null" json:"phone" form:"phone"` + URL string `gorm:"type:varchar(254);not null" json:"url" form:"url"` + Email string `gorm:"type:varchar(254);not null" json:"email" form:"email"` + Facebook string `gorm:"type:varchar(254)" json:"facebook,omitempty" form:"facebook"` + X string `gorm:"type:varchar(254)" json:"x,omitempty" form:"x"` + Instagram string `gorm:"type:varchar(254)" json:"instagram,omitempty" form:"instagram"` + Whatsapp string `gorm:"type:varchar(254)" json:"whatsapp,omitempty" form:"whatsapp"` + Pinterest string `gorm:"type:varchar(254)" json:"pinterest,omitempty" form:"pinterest"` + Linkedin string `gorm:"type:varchar(254)" json:"linkedin,omitempty" form:"linkedin"` + Slogan string `gorm:"type:varchar(254)" json:"slogan,omitempty" form:"slogan"` + Address string `gorm:"type:text" json:"address,omitempty" form:"address"` + Copyright string `gorm:"type:varchar(254)" json:"copyright,omitempty" form:"copyright"` + MapEmbed string `gorm:"type:text" json:"map_embed,omitempty" form:"map_embed"` + WLogo string `gorm:"type:text" json:"w_logo,omitempty" form:"w_logo"` + BLogo string `gorm:"type:text" json:"b_logo,omitempty" form:"b_logo"` + IsActive bool `gorm:"default:false" json:"is_active" form:"is_active"` + WWidth int `gorm:"default:0" json:"w_width" form:"w_width"` + WHeight int `gorm:"default:0" json:"w_height" form:"w_height"` + WQuality int `gorm:"default:0" json:"w_quality" form:"w_quality"` + WFormat string `gorm:"type:varchar(10)" json:"w_format" form:"w_format"` + BWidth int `gorm:"default:0" json:"b_width" form:"b_width"` + BHeight int `gorm:"default:0" json:"b_height" form:"b_height"` + BQuality int `gorm:"default:0" json:"b_quality" form:"b_quality"` + BFormat string `gorm:"type:varchar(10)" json:"b_format" form:"b_format"` +} + +// TableName overrides the table name used by Setting to `settings` +func (Setting) TableName() string { + return "settings" +} diff --git a/app/shop/controllers/shop.go b/app/shop/controllers/shop.go new file mode 100644 index 0000000..80c1ee1 --- /dev/null +++ b/app/shop/controllers/shop.go @@ -0,0 +1,665 @@ +package controllers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + shopModels "goaresv3/app/shop/models" + "goaresv3/config" +) + +type UpsertProductCategoryRequest struct { + Title string `json:"title" binding:"required,max=254"` + Slug string `json:"slug" binding:"required,max=254"` + Description string `json:"description"` + Keywords string `json:"keywords"` + ParentID *uint `json:"parent_id"` +} + +type UpsertProductTagRequest struct { + Name string `json:"name" binding:"required,max=254"` +} + +type UpsertProductRequest struct { + Title string `json:"title" binding:"required,max=254"` + Images string `json:"images" binding:"required"` + Price float64 `json:"price"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Format string `json:"format" binding:"omitempty,max=10"` + Content string `json:"content"` + Slug string `json:"slug" binding:"required,max=254"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` +} + +type UpsertCartItemRequest struct { + ProductID uint `json:"product_id" binding:"required"` + Quantity int `json:"quantity" binding:"required,min=1"` +} + +func parseShopID(c *gin.Context, key string) (uint, bool) { + id, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return 0, false + } + return uint(id), true +} + +// ListProductCategories godoc +// @Summary List product categories +// @Description Returns all categories with children. +// @Tags Shop +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/categories [get] +func ListProductCategories(c *gin.Context) { + var items []shopModels.ProductCategory + if err := config.DB.Preload("Children").Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch categories"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateProductCategory godoc +// @Summary Create product category +// @Description Creates a new shop category. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertProductCategoryRequest true "category payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/shop/categories [post] +func CreateProductCategory(c *gin.Context) { + var req UpsertProductCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := shopModels.ProductCategory{ + Title: req.Title, + Slug: req.Slug, + Description: req.Description, + Keywords: req.Keywords, + ParentID: req.ParentID, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create category"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateProductCategory godoc +// @Summary Update product category +// @Description Updates a category by id. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "category id" +// @Param request body UpsertProductCategoryRequest true "category payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/shop/categories/{id} [put] +func UpdateProductCategory(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + var req UpsertProductCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item shopModels.ProductCategory + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch category"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "title": req.Title, + "slug": req.Slug, + "description": req.Description, + "keywords": req.Keywords, + "parent_id": req.ParentID, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update category"}) + return + } + _ = config.DB.First(&item, id).Error + c.JSON(http.StatusOK, item) +} + +// DeleteProductCategory godoc +// @Summary Delete product category +// @Description Deletes a category by id. +// @Tags Shop +// @Security BearerAuth +// @Produce json +// @Param id path int true "category id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/categories/{id} [delete] +func DeleteProductCategory(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + res := config.DB.Delete(&shopModels.ProductCategory{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete category"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "category deleted"}) +} + +// ListProductTags godoc +// @Summary List product tags +// @Description Returns all product tags. +// @Tags Shop +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/tags [get] +func ListProductTags(c *gin.Context) { + var items []shopModels.ProductTag + if err := config.DB.Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch tags"}) + return + } + c.JSON(http.StatusOK, items) +} + +// CreateProductTag godoc +// @Summary Create product tag +// @Description Creates a new product tag. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertProductTagRequest true "tag payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/shop/tags [post] +func CreateProductTag(c *gin.Context) { + var req UpsertProductTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := shopModels.ProductTag{Name: req.Name} + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create tag"}) + return + } + c.JSON(http.StatusCreated, item) +} + +// UpdateProductTag godoc +// @Summary Update product tag +// @Description Updates a tag by id. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "tag id" +// @Param request body UpsertProductTagRequest true "tag payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/shop/tags/{id} [put] +func UpdateProductTag(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + var req UpsertProductTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item shopModels.ProductTag + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch tag"}) + return + } + if err := config.DB.Model(&item).Update("name", req.Name).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update tag"}) + return + } + _ = config.DB.First(&item, id).Error + c.JSON(http.StatusOK, item) +} + +// DeleteProductTag godoc +// @Summary Delete product tag +// @Description Deletes a tag by id. +// @Tags Shop +// @Security BearerAuth +// @Produce json +// @Param id path int true "tag id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/tags/{id} [delete] +func DeleteProductTag(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + res := config.DB.Delete(&shopModels.ProductTag{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete tag"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "tag deleted"}) +} + +// ListProducts godoc +// @Summary List products +// @Description Returns all products with categories and tags. +// @Tags Shop +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/products [get] +func ListProducts(c *gin.Context) { + var items []shopModels.Product + if err := config.DB.Preload("Categories").Preload("Tags").Order("id DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch products"}) + return + } + c.JSON(http.StatusOK, items) +} + +// GetProduct godoc +// @Summary Get product +// @Description Returns product details by id. +// @Tags Shop +// @Produce json +// @Param id path int true "product id" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/products/{id} [get] +func GetProduct(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + var item shopModels.Product + if err := config.DB.Preload("Categories").Preload("Tags").First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch product"}) + return + } + c.JSON(http.StatusOK, item) +} + +// CreateProduct godoc +// @Summary Create product +// @Description Creates a new product and assigns category/tag relations. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertProductRequest true "product payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/products [post] +func CreateProduct(c *gin.Context) { + var req UpsertProductRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := shopModels.Product{ + Title: req.Title, + Images: req.Images, + Price: req.Price, + Width: req.Width, + Height: req.Height, + Quality: req.Quality, + Format: req.Format, + Content: req.Content, + Slug: req.Slug, + } + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to create product"}) + return + } + if err := assignProductRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign product relations"}) + return + } + _ = config.DB.Preload("Categories").Preload("Tags").First(&item, item.ID).Error + c.JSON(http.StatusCreated, item) +} + +// UpdateProduct godoc +// @Summary Update product +// @Description Updates a product and reassigns category/tag relations. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "product id" +// @Param request body UpsertProductRequest true "product payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/products/{id} [put] +func UpdateProduct(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + var req UpsertProductRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item shopModels.Product + if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch product"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "title": req.Title, + "images": req.Images, + "price": req.Price, + "width": req.Width, + "height": req.Height, + "quality": req.Quality, + "format": req.Format, + "content": req.Content, + "slug": req.Slug, + }).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "failed to update product"}) + return + } + if err := assignProductRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign product relations"}) + return + } + _ = config.DB.Preload("Categories").Preload("Tags").First(&item, id).Error + c.JSON(http.StatusOK, item) +} + +// DeleteProduct godoc +// @Summary Delete product +// @Description Deletes a product by id. +// @Tags Shop +// @Security BearerAuth +// @Produce json +// @Param id path int true "product id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/products/{id} [delete] +func DeleteProduct(c *gin.Context) { + id, ok := parseShopID(c, "id") + if !ok { + return + } + res := config.DB.Delete(&shopModels.Product{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete product"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "product deleted"}) +} + +// GetMyCart godoc +// @Summary Get my cart +// @Description Returns the authenticated user's cart with items. +// @Tags Shop +// @Security BearerAuth +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/cart [get] +func GetMyCart(c *gin.Context) { + userID := c.GetUint("user_id") + var cart shopModels.Cart + err := config.DB.Preload("Items.Product").Where("user_id = ?", userID).First(&cart).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, gin.H{"items": []any{}}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart"}) + return + } + c.JSON(http.StatusOK, cart) +} + +// AddCartItem godoc +// @Summary Add item to my cart +// @Description Creates or increments a cart item for authenticated user. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param request body UpsertCartItemRequest true "cart item payload" +// @Success 200 {object} map[string]interface{} +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/cart/items [post] +func AddCartItem(c *gin.Context) { + userID := c.GetUint("user_id") + var req UpsertCartItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + cart, err := ensureCart(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get cart"}) + return + } + + var item shopModels.CartItem + err = config.DB.Where("cart_id = ? AND product_id = ?", cart.ID, req.ProductID).First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + item = shopModels.CartItem{CartID: cart.ID, ProductID: req.ProductID, Quantity: req.Quantity} + if err := config.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add cart item"}) + return + } + c.JSON(http.StatusCreated, item) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart item"}) + return + } + if err := config.DB.Model(&item).Update("quantity", item.Quantity+req.Quantity).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cart item"}) + return + } + _ = config.DB.First(&item, item.ID).Error + c.JSON(http.StatusOK, item) +} + +// UpdateCartItem godoc +// @Summary Update my cart item +// @Description Updates a cart item owned by authenticated user. +// @Tags Shop +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param itemId path int true "cart item id" +// @Param request body UpsertCartItemRequest true "cart item payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/cart/items/{itemId} [put] +func UpdateCartItem(c *gin.Context) { + itemID, ok := parseShopID(c, "itemId") + if !ok { + return + } + userID := c.GetUint("user_id") + var req UpsertCartItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item shopModels.CartItem + err := config.DB.Joins("JOIN carts ON carts.id = cart_items.cart_id"). + Where("cart_items.id = ? AND carts.user_id = ?", itemID, userID). + First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "cart item not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart item"}) + return + } + if err := config.DB.Model(&item).Updates(map[string]any{ + "product_id": req.ProductID, + "quantity": req.Quantity, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cart item"}) + return + } + _ = config.DB.First(&item, itemID).Error + c.JSON(http.StatusOK, item) +} + +// DeleteCartItem godoc +// @Summary Delete item from my cart +// @Description Deletes a cart item owned by authenticated user. +// @Tags Shop +// @Security BearerAuth +// @Produce json +// @Param itemId path int true "cart item id" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shop/cart/items/{itemId} [delete] +func DeleteCartItem(c *gin.Context) { + itemID, ok := parseShopID(c, "itemId") + if !ok { + return + } + userID := c.GetUint("user_id") + var item shopModels.CartItem + err := config.DB.Joins("JOIN carts ON carts.id = cart_items.cart_id"). + Where("cart_items.id = ? AND carts.user_id = ?", itemID, userID). + First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "cart item not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart item"}) + return + } + if err := config.DB.Delete(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete cart item"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "cart item deleted"}) +} + +func ensureCart(userID uint) (*shopModels.Cart, error) { + var cart shopModels.Cart + err := config.DB.Where("user_id = ?", userID).First(&cart).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + cart = shopModels.Cart{UserID: userID} + if createErr := config.DB.Create(&cart).Error; createErr != nil { + return nil, createErr + } + return &cart, nil + } + if err != nil { + return nil, err + } + return &cart, nil +} + +func assignProductRelations(productID uint, categoryIDs, tagIDs []uint) error { + var p shopModels.Product + if err := config.DB.First(&p, productID).Error; err != nil { + return err + } + if categoryIDs != nil { + var categories []shopModels.ProductCategory + if len(categoryIDs) > 0 { + if err := config.DB.Where("id IN ?", categoryIDs).Find(&categories).Error; err != nil { + return err + } + } + if err := config.DB.Model(&p).Association("Categories").Replace(categories); err != nil { + return err + } + } + if tagIDs != nil { + var tags []shopModels.ProductTag + if len(tagIDs) > 0 { + if err := config.DB.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil { + return err + } + } + if err := config.DB.Model(&p).Association("Tags").Replace(tags); err != nil { + return err + } + } + return nil +} diff --git a/app/shop/models/cart.go b/app/shop/models/cart.go new file mode 100644 index 0000000..658677c --- /dev/null +++ b/app/shop/models/cart.go @@ -0,0 +1,20 @@ +package models + +import ( + "gorm.io/gorm" +) + +type Cart struct { + gorm.Model + UserID uint `gorm:"not null;index" json:"user_id"` + Items []CartItem `gorm:"foreignKey:CartID" json:"items,omitempty"` +} + +type CartItem struct { + gorm.Model + CartID uint `gorm:"not null;index" json:"cart_id"` + Cart *Cart `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:CartID" json:"cart,omitempty"` + ProductID uint `gorm:"not null;index" json:"product_id"` + Product *Product `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:ProductID" json:"product,omitempty"` + Quantity int `gorm:"default:1" json:"quantity"` +} diff --git a/app/shop/models/product.go b/app/shop/models/product.go new file mode 100644 index 0000000..6e16827 --- /dev/null +++ b/app/shop/models/product.go @@ -0,0 +1,53 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Minimal, temiz GORM modelleri + +type ProductCategory struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"` + Description string `json:"description,omitempty"` + Keywords string `json:"keywords,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` + Parent *ProductCategory `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"` + Children []ProductCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Products []Product `gorm:"many2many:product_product_categories;" json:"products,omitempty"` +} + +type ProductTag struct { + gorm.Model + Name string `gorm:"type:varchar(254);not null" json:"name"` + Products []Product `gorm:"many2many:product_product_tags;" json:"products,omitempty"` +} + +type Product struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"` + Images string `gorm:"type:text;not null" json:"images" form:"images"` + Price float64 `gorm:"type:decimal(10,2);default:0.0" json:"price" form:"price"` + Width int `gorm:"default:0" json:"width" form:"width"` + Height int `gorm:"default:0" json:"height" form:"height"` + Quality int `gorm:"default:0" json:"quality" form:"quality"` + Format string `gorm:"type:varchar(10);default:avif" json:"format" form:"format"` + Content string `gorm:"type:text" json:"content,omitempty" form:"content"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug" form:"slug"` + Categories []ProductCategory `gorm:"many2many:product_product_categories;" json:"categories,omitempty" form:"product_category"` + Tags []ProductTag `gorm:"many2many:product_product_tags;" json:"tags,omitempty" form:"tags"` +} + +type ProductCategoryView struct { + gorm.Model + CategoryID uint `json:"category_id"` + IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"` +} + +type ProductComment struct { + gorm.Model + UserID uint `json:"user_id"` + ProductID uint `json:"product_id"` + Body string `gorm:"type:text" json:"body,omitempty"` +} diff --git a/belgeler/README.md b/belgeler/README.md new file mode 100644 index 0000000..bf86f5c --- /dev/null +++ b/belgeler/README.md @@ -0,0 +1,12 @@ +# goaresv3 — Proje Belgeleri + +Bu klasör, projenin teknik belgelerini içerir. + +| Dosya | Konu | +|---|---| +| [proje-yapisi.md](proje-yapisi.md) | Klasör ve dosya ağacı, her birinin rolü | +| [auth.md](auth.md) | Kimlik doğrulama sistemi (Register / Login / JWT) | +| [api-referans.md](api-referans.md) | Tüm endpoint'ler, istek/yanıt örnekleri | +| [veritabani.md](veritabani.md) | GORM modelleri ve veritabanı şeması | +| [ortam-degiskenleri.md](ortam-degiskenleri.md) | `.env` değişkenleri ve açıklamaları | +| [testler.md](testler.md) | Otomatik test kapsamı ve çalıştırma rehberi | diff --git a/belgeler/api-referans.md b/belgeler/api-referans.md new file mode 100644 index 0000000..4a74342 --- /dev/null +++ b/belgeler/api-referans.md @@ -0,0 +1,267 @@ +# API Referansı + +Base URL: `http://localhost:8080` + +--- + +## Public Endpoint'ler +_Token gerektirmez._ + +--- + +### `POST /api/v1/auth/register` + +Yeni kullanıcı oluşturur ve doğrulama maili gönderir. + +**İstek gövdesi** +```json +{ + "username": "ali", + "email": "ali@ornek.com", + "password": "gizli1234", + "confirm_password": "gizli1234" +} +``` + +| Alan | Tip | Kural | +|---|---|---| +| `username` | string | Zorunlu, 3–50 karakter | +| `email` | string | Zorunlu, geçerli e-posta | +| `password` | string | Zorunlu, min 8 karakter | +| `confirm_password` | string | Zorunlu, `password` ile aynı olmalı | + +**Yanıtlar** + +| Durum | Açıklama | Gövde örneği | +|---|---|---| +| `201 Created` | Kayıt başarılı, doğrulama maili gönderildi | `{ "message": "user created. please verify your email", "user_id": 1 }` | +| `400 Bad Request` | Doğrulama hatası | `{ "error": "Key: 'RegisterRequest.Email' Error:..." }` | +| `400 Bad Request` | Şifreler eşleşmiyor | `{ "error": "password and confirm_password do not match" }` | +| `409 Conflict` | E-posta zaten kayıtlı | `{ "error": "email already in use" }` | +| `500 Internal Server Error` | Doğrulama maili gönderilemedi | `{ "error": "failed to send verification email" }` | + +--- + +### `GET /api/v1/auth/verify-email` + +Kullanıcı email doğrulama linkindeki token ile hesabı aktive eder. + +**Query parametreleri** + +| Alan | Tip | Kural | +|---|---|---| +| `token` | string | Zorunlu | + +**Yanıtlar** + +| Durum | Açıklama | Gövde örneği | +|---|---|---| +| `200 OK` | Email doğrulandı | `{ "message": "email verified successfully" }` | +| `400 Bad Request` | Token eksik/geçersiz | `{ "error": "invalid or expired verification token" }` | +| `500 Internal Server Error` | Sunucu hatası | `{ "error": "could not verify email" }` | + +--- + +### `POST /api/v1/auth/login` + +E-posta ve şifre ile giriş yapar; token çifti döner. + +**İstek gövdesi** +```json +{ + "email": "ali@ornek.com", + "password": "gizli1234" +} +``` + +**Yanıtlar** + +| Durum | Açıklama | Gövde örneği | +|---|---|---| +| `200 OK` | Giriş başarılı | Aşağıya bakın | +| `400 Bad Request` | Eksik alan | `{ "error": "..." }` | +| `401 Unauthorized` | Yanlış bilgi | `{ "error": "invalid email or password" }` | +| `403 Forbidden` | Email doğrulanmamış | `{ "error": "email is not verified" }` | + +```json +// 200 OK +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer" +} +``` + +--- + +### `POST /api/v1/auth/refresh` + +Süresi dolmuş access token'ı yeniler. + +**İstek gövdesi** +```json +{ "refresh_token": "eyJ..." } +``` + +**Yanıtlar** + +| Durum | Açıklama | Gövde örneği | +|---|---|---| +| `200 OK` | Yenileme başarılı | `{ "access_token": "eyJ...", "token_type": "Bearer" }` | +| `400 Bad Request` | Eksik alan | `{ "error": "..." }` | +| `401 Unauthorized` | Token geçersiz/süresi dolmuş | `{ "error": "invalid or expired refresh token" }` | +| `403 Forbidden` | Email doğrulanmamış | `{ "error": "email is not verified" }` | + +--- + +## Korumalı Endpoint'ler +_`Authorization: Bearer ` başlığı zorunludur._ + +--- + +### `GET /api/v1/me` + +Oturum açmış kullanıcının kimlik bilgilerini döner. + +**İstek başlığı** +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Yanıtlar** + +| Durum | Açıklama | Gövde örneği | +|---|---|---| +| `200 OK` | Başarılı | `{ "user_id": 1, "email": "ali@ornek.com", "username": "ali" }` | +| `401 Unauthorized` | Başlık eksik veya token geçersiz | `{ "error": "authorization header missing or malformed" }` | + +--- + +## Admin Yetkisi Gerektiren Endpoint'ler + +Bu endpointler için kullanıcı hem giriş yapmış olmalı hem de `is_admin=true` olmalıdır. + +### Settings + +- `PUT /api/v1/settings` +- `POST /api/v1/settings/heroes` +- `PUT /api/v1/settings/heroes/{id}` +- `DELETE /api/v1/settings/heroes/{id}` +- `POST /api/v1/settings/cors/whitelist` +- `PUT /api/v1/settings/cors/whitelist/{id}` +- `DELETE /api/v1/settings/cors/whitelist/{id}` +- `POST /api/v1/settings/cors/blacklist` +- `PUT /api/v1/settings/cors/blacklist/{id}` +- `DELETE /api/v1/settings/cors/blacklist/{id}` +- `POST /api/v1/settings/rate-limits` +- `PUT /api/v1/settings/rate-limits/{id}` +- `DELETE /api/v1/settings/rate-limits/{id}` + +### Shop + +- `POST /api/v1/shop/categories` +- `PUT /api/v1/shop/categories/{id}` +- `DELETE /api/v1/shop/categories/{id}` +- `POST /api/v1/shop/tags` +- `PUT /api/v1/shop/tags/{id}` +- `DELETE /api/v1/shop/tags/{id}` +- `POST /api/v1/shop/products` +- `PUT /api/v1/shop/products/{id}` +- `DELETE /api/v1/shop/products/{id}` + +### Blog + +- `POST /api/v1/blog/categories` +- `PUT /api/v1/blog/categories/{id}` +- `DELETE /api/v1/blog/categories/{id}` +- `POST /api/v1/blog/tags` +- `PUT /api/v1/blog/tags/{id}` +- `DELETE /api/v1/blog/tags/{id}` +- `POST /api/v1/blog/posts` +- `PUT /api/v1/blog/posts/{id}` +- `DELETE /api/v1/blog/posts/{id}` + +--- + +## Auth Gerekli (Okuma / Kullanıcı İşlemleri) + +### Settings (read) +- `GET /api/v1/settings` +- `GET /api/v1/settings/heroes` +- `GET /api/v1/settings/cors/whitelist` +- `GET /api/v1/settings/cors/blacklist` +- `GET /api/v1/settings/rate-limits` + +### Shop +- `GET /api/v1/shop/categories` +- `GET /api/v1/shop/tags` +- `GET /api/v1/shop/products` +- `GET /api/v1/shop/products/{id}` +- `GET /api/v1/shop/cart` +- `POST /api/v1/shop/cart/items` +- `PUT /api/v1/shop/cart/items/{itemId}` +- `DELETE /api/v1/shop/cart/items/{itemId}` + +### Blog +- `GET /api/v1/blog/categories` +- `GET /api/v1/blog/tags` +- `GET /api/v1/blog/posts` +- `GET /api/v1/blog/posts/{id}` + +--- + +## Runtime Güvenlik Davranışı + +- **CORS blacklist**: origin varsa doğrudan blok (`403`). +- **CORS whitelist**: origin whitelist'te ise rate-limit muaf. +- **Whitelist/blacklist dışı**: CORS geçer, rate-limit uygulanır. +- **Rate limit kural sırası**: + 1. Endpoint adı (`api/v1/auth/login` gibi) + 2. Fallback `api` + +> En güncel endpoint listesi ve request/response şemaları için Swagger UI kullanın. + +--- + +## Hata Formatı + +Tüm hata yanıtları aynı yapıdadır: + +```json +{ "error": "açıklayıcı hata mesajı" } +``` + +--- + +## curl Örnekleri + +```bash +# Kayıt +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"ali","email":"ali@ornek.com","password":"gizli1234","confirm_password":"gizli1234"}' + +# Email doğrulama +curl "http://localhost:8080/api/v1/auth/verify-email?token=" + +# Giriş +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"ali@ornek.com","password":"gizli1234"}' + +# Korumalı endpoint +curl http://localhost:8080/api/v1/me \ + -H "Authorization: Bearer " + +# Token yenile +curl -X POST http://localhost:8080/api/v1/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refresh_token":""}' +``` + +--- + +## Swagger + +- UI: `http://localhost:8080/swagger/index.html` +- OpenAPI dosyaları: `docs/swagger.json`, `docs/swagger.yaml` diff --git a/belgeler/auth.md b/belgeler/auth.md new file mode 100644 index 0000000..8b43f92 --- /dev/null +++ b/belgeler/auth.md @@ -0,0 +1,201 @@ +# Kimlik Doğrulama Sistemi + +## Genel Bakış + +Proje email + şifre tabanlı kimlik doğrulama kullanır. Başarılı girişte iki token döner: + +| Token | Ömür | Kullanım | +|---|---|---| +| `access_token` | 15 dakika | Her korumalı isteğe `Authorization: Bearer` başlığı | +| `refresh_token` | 7 gün | Süresi dolan access token'ı yenilemek için | + +Ek kural: Email doğrulaması tamamlanmadan login ve refresh işlemleri engellenir. + +Algoritma: **HS256** (HMAC-SHA256) + +--- + +## Akış Diyagramı + +``` +┌─────────┐ POST /api/v1/auth/register ┌─────────┐ +│ Client │ ────────────────────────────────────► │ Server │ +│ │ ◄──── 201 { user_id } + verify mail │ │ +└─────────┘ └─────────┘ + +┌─────────┐ GET /api/v1/auth/verify-email ┌─────────┐ +│ Client │ ── token linki ile ──────────────────► │ Server │ +│ │ ◄──── 200 email verified ──────────── │ │ +└─────────┘ └─────────┘ + +┌─────────┐ POST /api/v1/auth/login ┌─────────┐ +│ Client │ ── { email, password } ──────────────► │ Server │ +│ │ ◄── 200 { access_token, │ │ +│ │ refresh_token } ───────────── └─────────┘ +└─────────┘ + +┌─────────┐ GET /api/v1/me ┌─────────┐ +│ Client │ ── Authorization: Bearer ───► │ Server │ +│ │ ◄── 200 { user_id, email, username } │ (auth │ +└─────────┘ │ middleware) + └─────────┘ + +┌─────────┐ POST /api/v1/auth/refresh ┌─────────┐ +│ Client │ ── { refresh_token } ────────────────► │ Server │ +│ │ ◄── 200 { access_token } ───────────── └─────────┘ +└─────────┘ +``` + +--- + +## Kayıt (`Register`) + +**Dosya:** `app/accounts/controllers/user.go` → `Register()` + +1. JSON body doğrulanır (`username` min 3, `email` geçerli format, `password` min 8 karakter, `confirm_password` min 8 karakter). +2. `password` ve `confirm_password` eşleşmesi kontrol edilir. +3. Şifre `bcrypt.DefaultCost` ile hash'lenir. +4. `User` kaydı veritabanına yazılır. +5. Rastgele bir email doğrulama token'ı üretilir ve kullanıcıya yazılır. +6. Kullanıcıya doğrulama maili gönderilir. +7. `email` alanı unique index'li; duplicate durumunda `409 Conflict` döner. + +Not: Mail gönderimi başarısız olursa oluşturulan kullanıcı kaydı geri silinir. + +```json +// İstek +{ + "username": "ali", + "email": "ali@ornek.com", + "password": "gizli1234", + "confirm_password": "gizli1234" +} + +// Başarılı yanıt 201 +{ "message": "user created. please verify your email", "user_id": 1 } +``` + +## Email Doğrulama (`VerifyEmail`) + +**Dosya:** `app/accounts/controllers/user.go` → `VerifyEmail()` + +1. Query string içinden `token` alınır. +2. Token ile kullanıcı bulunur. +3. `email_verified=true`, `email_verified_at=now`, `email_verify_token=""` olarak güncellenir. + +```json +// Başarılı yanıt 200 +{ "message": "email verified successfully" } +``` + +--- + +## Giriş (`Login`) + +**Dosya:** `app/accounts/controllers/user.go` → `Login()` + +1. Email ile kullanıcı aranır. +2. `email_verified` kontrol edilir; doğrulanmamış hesaplara `403` dönülür. +3. `bcrypt.CompareHashAndPassword` ile şifre doğrulanır. +4. Her iki adımda da hata mesajı aynı tutulur — kullanıcı numaralandırma saldırısına karşı. +5. Başarıda access + refresh token döner. + +```json +// İstek +{ "email": "ali@ornek.com", "password": "gizli1234" } + +// Başarılı yanıt 200 +{ + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "token_type": "Bearer" +} +``` + +--- + +## Token Yenileme (`RefreshToken`) + +**Dosya:** `app/accounts/controllers/user.go` → `RefreshToken()` + +1. Body'den `refresh_token` alınır. +2. `JWT_REFRESH_SECRET` ile doğrulanır. +3. Kullanıcının `email_verified` durumu kontrol edilir; doğrulanmamışsa `403` döner. +4. Claims'ten `user_id`, `email`, `username` alanları kullanılarak yeni access token üretilir. +5. Refresh token yenilenmez (sliding window değil, sabit pencere). + +```json +// İstek +{ "refresh_token": "eyJ..." } + +// Başarılı yanıt 200 +{ "access_token": "eyJ...", "token_type": "Bearer" } +``` + +--- + +## Google Login (OAuth 2.0) + +**Dosya:** `app/accounts/controllers/user.go` → `GoogleLogin()` ve `GoogleCallback()` + +Akış: + +1. `GET /api/v1/auth/google/login` +2. Sunucu CSRF için `state` üretir, cookie'ye yazar ve `auth_url` döner. +3. Client kullanıcıyı `auth_url` adresine yönlendirir. +4. Google kullanıcıyı callback'e döndürür: `GET /api/v1/auth/google/callback?state=...&code=...` +5. Sunucu `state` doğrular, `code` ile Google'dan token alır. +6. Google `userinfo` verisi çekilir. +7. Kullanıcı email'e göre bulunur/oluşturulur, `SocialAccount(provider=google)` kaydı bağlanır. +8. Google ile gelen kullanıcı email'i doğrulanmış kabul edilir (`email_verified=true`). +9. Yerel `access_token` + `refresh_token` üretilip döndürülür. + +Yeni endpointler: + +- `GET /api/v1/auth/google/login` +- `GET /api/v1/auth/google/callback` + +--- + +## GitHub Login (OAuth 2.0) + +**Dosya:** `app/accounts/controllers/user.go` → `GitHubLogin()` ve `GitHubCallback()` + +Akış: + +1. `GET /api/v1/auth/github/login` +2. Sunucu CSRF için `state` üretir, cookie'ye yazar ve `auth_url` döner. +3. Client kullanıcıyı `auth_url` adresine yönlendirir. +4. GitHub callback'e döndürür: `GET /api/v1/auth/github/callback?state=...&code=...` +5. Sunucu `state` doğrular, `code` ile GitHub'dan token alır. +6. GitHub profil/email bilgisi çekilir. +7. Kullanıcı email'e göre bulunur/oluşturulur, `SocialAccount(provider=github)` kaydı bağlanır. +8. GitHub ile gelen kullanıcı email'i doğrulanmış kabul edilir (`email_verified=true`). +9. Yerel `access_token` + `refresh_token` üretilip döndürülür. + +Yeni endpointler: + +- `GET /api/v1/auth/github/login` +- `GET /api/v1/auth/github/callback` + +--- + +## Güvenlik Notları + +- Şifreler veritabanında **asla düz metin** tutulmaz; bcrypt hash'i saklanır. +- `Password` alanı modelde `json:"-"` ile işaretlidir — API yanıtlarında görünmez. +- Access ve refresh token farklı secret'larla imzalanır (`JWT_SECRET` / `JWT_REFRESH_SECRET`). +- Production'da her iki secret'ın en az 32 karakter uzunluğunda, rastgele olması gerekir. +- HTTPS zorunludur; token'lar aktarım sırasında şifrelenmez. +- Register adımında şifre uyumsuzluğu için `400 Bad Request` dönülür: `password and confirm_password do not match`. + +--- + +## Swagger + +- Swagger UI: `/swagger/index.html` +- Doküman üretme komutu: + +```bash +$(go env GOPATH)/bin/swag init -g main.go +``` diff --git a/belgeler/ortam-degiskenleri.md b/belgeler/ortam-degiskenleri.md new file mode 100644 index 0000000..b18b0bf --- /dev/null +++ b/belgeler/ortam-degiskenleri.md @@ -0,0 +1,151 @@ +# Ortam Değişkenleri + +Proje kök dizinindeki `.env` dosyasından veya sistem ortam değişkenlerinden okunur. +`.env` yoksa sistem değişkenleri kullanılır (ör. Docker, CI ortamları). + +--- + +## Veritabanı + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `DB_HOST` | `localhost` | MySQL sunucu adresi | +| `DB_PORT` | `3306` | MySQL port | +| `DB_USER` | `root` | Veritabanı kullanıcı adı | +| `DB_PASSWORD` | _(boş)_ | Veritabanı şifresi | +| `DB_NAME` | `goaresv3` | Veritabanı adı | + +--- + +## JWT + +| Değişken | Açıklama | +|---|---| +| `JWT_SECRET` | Access token imzalama anahtarı (min 32 karakter önerilir) | +| `JWT_REFRESH_SECRET` | Refresh token imzalama anahtarı (min 32 karakter önerilir) | + +> **Uyarı:** Her iki secret production ortamda farklı, uzun ve rastgele olmalıdır. +> Örnek üretim komutu: +> ```bash +> openssl rand -hex 32 +> ``` + +--- + +## Uygulama + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `PORT` | `8080` | HTTP sunucu portu | +| `APP_BASE_URL` | `http://localhost:8080` | Email doğrulama linki oluşturmak için temel adres | + +--- + +## Google OAuth + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `SOCIAL_AUTH_GOOGLE_OAUTH2_KEY` | - | Google OAuth istemci ID | +| `SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET` | - | Google OAuth istemci secret | +| `SOCIAL_AUTH_GOOGLE_REDIRECT_URL` | - | Google callback URL (ör: `http://localhost:8080/api/v1/auth/google/callback`) | +| `SOCIAL_AUTH_GOOGLE_SCOPES` | `openid,email,profile` | İsteğe bağlı scope listesi (virgülle ayrılmış) | + +--- + +## GitHub OAuth + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `SOCIAL_AUTH_GITHUB_KEY` | - | GitHub OAuth istemci ID | +| `SOCIAL_AUTH_GITHUB_SECRET` | - | GitHub OAuth istemci secret | +| `SOCIAL_AUTH_GITHUB_REDIRECT_URL` | - | GitHub callback URL (ör: `http://localhost:8080/api/v1/auth/github/callback`) | +| `SOCIAL_AUTH_GITHUB_SCOPES` | `read:user,user:email` | İsteğe bağlı scope listesi (virgülle ayrılmış) | + +--- + +## CORS Bootstrap + +> Bu değerler sadece **ilk açılış seed** için kullanılır. +> DB'de kayıt varsa üzerine yazılmaz. + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `CORS_BOOTSTRAP_WHITELIST_ORIGINS` | _(boş)_ | Virgülle ayrılmış origin listesi (ör: `http://localhost:3000,https://admin.ornek.com`) | +| `CORS_BOOTSTRAP_BLACKLIST_ORIGINS` | _(boş)_ | Virgülle ayrılmış engelli origin listesi | + +--- + +## Rate Limit Bootstrap + +> Bu değerler sadece **ilk açılış seed** için kullanılır. +> `rate_limit_settings` tablosunda aynı `name` varsa değiştirilmez. + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `RL_BOOTSTRAP_LOGIN_MAX_REQUESTS` | `10` | `api/v1/auth/login` için maksimum istek | +| `RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS` | `60` | Login penceresi (sn) | +| `RL_BOOTSTRAP_REGISTER_MAX_REQUESTS` | `5` | `api/v1/auth/register` için maksimum istek | +| `RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS` | `60` | Register penceresi (sn) | +| `RL_BOOTSTRAP_API_MAX_REQUESTS` | `120` | Diğer endpointler için fallback `api` limiti | +| `RL_BOOTSTRAP_API_WINDOW_SECONDS` | `60` | Genel fallback pencere (sn) | + +--- + +## Email + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `EMAIL_HOST` | - | SMTP sunucu adresi | +| `EMAIL_PORT` | - | SMTP port | +| `EMAIL_HOST_USER` | _(boş olabilir)_ | SMTP kullanıcı adı | +| `EMAIL_HOST_PASSWORD` | _(boş olabilir)_ | SMTP şifresi | +| `EMAIL_USE_TLS` | `false` | STARTTLS kullanılsın mı | +| `EMAIL_USE_SSL` | `false` | SMTPS (SSL/TLS) doğrudan bağlantı kullanılsın mı | +| `EMAIL_FROM` | - | Gönderici email adresi | + +--- + +## Örnek `.env` + +```dotenv +DB_HOST=localhost +DB_PORT=3307 +DB_USER=root +DB_PASSWORD=sifrem +DB_NAME=goaresv3 + +JWT_SECRET=uretilen-guclu-secret-1 +JWT_REFRESH_SECRET=uretilen-guclu-secret-2 + +PORT=8080 +APP_BASE_URL=http://localhost:8080 + +EMAIL_HOST=10.80.80.70 +EMAIL_PORT=1025 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=false +EMAIL_USE_SSL=false +EMAIL_FROM=noreply@gauth.local + +CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,http://localhost:5173 +CORS_BOOTSTRAP_BLACKLIST_ORIGINS= + +RL_BOOTSTRAP_LOGIN_MAX_REQUESTS=10 +RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS=60 +RL_BOOTSTRAP_REGISTER_MAX_REQUESTS=5 +RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS=60 +RL_BOOTSTRAP_API_MAX_REQUESTS=120 +RL_BOOTSTRAP_API_WINDOW_SECONDS=60 +``` + +--- + +## Güvenlik + +- `.env` dosyasını **asla** Git'e commit etmeyin. +- `.gitignore` dosyanıza ekleyin: + ``` + .env + ``` +- Staging/production ortamlarda değişkenleri CI/CD secret yöneticisi (GitHub Secrets, Vault) ile iletin. diff --git a/belgeler/proje-yapisi.md b/belgeler/proje-yapisi.md new file mode 100644 index 0000000..05dded2 --- /dev/null +++ b/belgeler/proje-yapisi.md @@ -0,0 +1,56 @@ +# Proje Yapısı + +``` +goaresv3/ +├── main.go +├── .env +├── app/ +│ ├── accounts/ +│ │ ├── controllers/user.go +│ │ └── models/accounts.go +│ ├── settings/ +│ │ ├── controllers/settings.go +│ │ └── models/{setting,hero,cors}.go +│ ├── shop/ +│ │ ├── controllers/shop.go +│ │ └── models/{product,cart}.go +│ └── blog/ +│ ├── controllers/blog.go +│ └── models/blog.go +├── config/database.go +├── pkg/ +│ ├── jwt/jwt.go +│ ├── mailer/mailer.go +│ ├── middleware/{auth,cors_dynamic,rate_limit_dynamic}.go +│ └── swaggerui/initializer.go +├── router/router.go +├── docs/{docs.go,swagger.json,swagger.yaml} +└── belgeler/ +``` + +## Uygulama Akışı + +1. `.env` yüklenir (`godotenv`). +2. `config.ConnectDB()` ile MySQL bağlantısı açılır. +3. `config.RunAutoMigrate()` tüm modüllerin şemalarını uygular. +4. `config.SeedSecurityDefaults()` CORS/RateLimit başlangıç kayıtlarını (yoksa) ekler. +5. Gin başlatılır, global middlewareler çalışır: + - `DynamicCORS()` + - `DynamicRateLimit()` +6. `router.Setup(r)` ile endpointler yüklenir. + +## Yetki Modeli + +- `AuthRequired()`: + - Access token doğrular + - Context'e `user_id`, `email`, `username` yazar +- `AdminRequired()`: + - `users.is_admin` alanını kontrol eder + - `POST/PUT/DELETE/PATCH` gibi mutating endpointlerde kullanılır + +## Route Grupları + +- Public: `/api/v1/auth/*` +- Auth zorunlu (read + user işlemleri): `/api/v1/*` +- Admin zorunlu (mutating yönetim işlemleri): `/api/v1/*` altında admin grubu +- Swagger UI: `/swagger/*any` diff --git a/belgeler/testler.md b/belgeler/testler.md new file mode 100644 index 0000000..46d1212 --- /dev/null +++ b/belgeler/testler.md @@ -0,0 +1,88 @@ +# Test Rehberi + +Bu dokuman projedeki otomatik test kapsamini ve calistirma adimlarini ozetler. + +## Calistirma + +Tum testleri calistirmak icin: + +```bash +go test ./... +``` + +Kapsam (coverage) ile calistirmak icin: + +```bash +go test -cover ./... +``` + +## Endpoint Test Matrisi + +Asagidaki endpointler otomatik testlerle, hem basarili hem hata senaryolariyla kapsanir: + +| Endpoint | Durum kodlari | Dosyalar | +|---|---|---| +| `POST /api/v1/auth/register` | `201`, `400`, `409`, `500` | [router/router_test.go](../router/router_test.go), [app/accounts/controllers/user_test.go](../app/accounts/controllers/user_test.go) | +| `GET /api/v1/auth/verify-email` | `200`, `400` | [router/router_test.go](../router/router_test.go), [app/accounts/controllers/user_test.go](../app/accounts/controllers/user_test.go) | +| `POST /api/v1/auth/login` | `200`, `400`, `401`, `403` | [router/router_test.go](../router/router_test.go), [app/accounts/controllers/user_test.go](../app/accounts/controllers/user_test.go) | +| `POST /api/v1/auth/refresh` | `200`, `400`, `401`, `403` | [router/router_test.go](../router/router_test.go), [app/accounts/controllers/user_test.go](../app/accounts/controllers/user_test.go) | +| `GET /api/v1/me` | `200`, `401` | [router/router_test.go](../router/router_test.go), [app/accounts/controllers/user_test.go](../app/accounts/controllers/user_test.go), [pkg/middleware/auth_test.go](../pkg/middleware/auth_test.go) | +| `GET /swagger/index.html` | `200` | [router/router_test.go](../router/router_test.go) | +| `POST /swagger/index.html` | `404` | [router/router_test.go](../router/router_test.go) | +| `GET /swagger/swagger-initializer.js` | `200` | [router/router_test.go](../router/router_test.go) | + +## Endpoint Senaryo Detayi + +### `POST /api/v1/auth/register` +- Basarili kayit ve mail gonderimi +- `password`/`confirm_password` uyusmazligi +- Duplicate email +- SMTP/config hatasinda register rollback + +### `GET /api/v1/auth/verify-email` +- Basarili token ile aktivasyon +- Token eksik +- Token gecersiz + +### `POST /api/v1/auth/login` +- Dogrulanmis hesapla basarili login +- Eksik/hatali request body +- Yanlis sifre veya bulunamayan email +- Dogrulanmamis email + +### `POST /api/v1/auth/refresh` +- Gecerli refresh token ile yeni access token +- Eksik request body +- Bozuk/gecersiz JWT +- Token kullanicisi bulunamadi +- Dogrulanmamis email + +### `GET /api/v1/me` +- Gecerli Bearer access token ile erisim +- Authorization header yok +- Raw token (Bearer olmadan) reddi +- Username context yoksa DB fallback + +## Paket Bazli Testler + +| Paket | Dosya | Kapsam | +|---|---|---| +| `pkg/jwt` | [pkg/jwt/jwt_test.go](../pkg/jwt/jwt_test.go) | Token uretim/dogrulama, yanlis secret davranisi | +| `pkg/middleware` | [pkg/middleware/auth_test.go](../pkg/middleware/auth_test.go), [pkg/middleware/dynamic_policies_test.go](../pkg/middleware/dynamic_policies_test.go) | Auth middleware + dynamic CORS/RateLimit davranisi | +| `pkg/mailer` | [pkg/mailer/mailer_test.go](../pkg/mailer/mailer_test.go) | SMTP config validasyonu ve fake SMTP ile gonderim | +| `app/accounts/controllers` | [app/accounts/controllers/user_test.go](../app/accounts/controllers/user_test.go) | Verify/Login/Refresh/Me handler seviyesinde davranis testleri | + +## Dynamic Policy Testleri + +`pkg/middleware/dynamic_policies_test.go` su senaryolari kapsar: + +- Blacklist origin'in CORS tarafinda bloklanmasi +- Whitelist origin'in rate-limitten muaf olmasi +- Whitelist/blacklist disi origin'e rate-limit uygulanmasi +- `login` ve `register` endpointlerine ayri limit kurallarinin calismasi + +## Notlar + +- Endpoint entegrasyon testi icin test icinde gecici SQLite veritabani kullanilir. +- Register testinde email gonderimi icin test icinde fake SMTP sunucusu ayaga kaldirilir. +- Testler ortamdan bagimsiz calisacak sekilde gerekli env degerlerini test icinde set eder. diff --git a/belgeler/veritabani.md b/belgeler/veritabani.md new file mode 100644 index 0000000..5491457 --- /dev/null +++ b/belgeler/veritabani.md @@ -0,0 +1,159 @@ +# Veritabanı + +ORM: **GORM v1.31** +Sürücü: **MySQL (gorm.io/driver/mysql)** +Karakter seti: `utf8mb4` + +--- + +## Tablolar + +### `users` + +`gorm.Model` gömülüdür → `id`, `created_at`, `updated_at`, `deleted_at` otomatik eklenir. + +| Sütun | Tip | Kısıtlama | Açıklama | +|---|---|---|---| +| `id` | BIGINT UNSIGNED | PK, AUTO_INCREMENT | — | +| `username` | VARCHAR(255) | — | Kullanıcı adı | +| `email` | VARCHAR(255) | UNIQUE, NOT NULL | Giriş e-postası | +| `password` | VARCHAR(255) | — | bcrypt hash (JSON'da gizli) | +| `email_verified` | TINYINT(1) | DEFAULT 0 | E-posta doğrulandı mı | +| `email_verify_token` | VARCHAR(255) | INDEX | Doğrulama token'ı | +| `email_verified_at` | DATETIME | NULL | Doğrulama zamanı | +| `is_admin` | TINYINT(1) | DEFAULT 0 | Yönetici mi | +| `created_at` | DATETIME | — | — | +| `updated_at` | DATETIME | — | — | +| `deleted_at` | DATETIME | INDEX, NULL | Soft-delete | + +--- + +### `social_accounts` + +| Sütun | Tip | Kısıtlama | Açıklama | +|---|---|---|---| +| `id` | BIGINT UNSIGNED | PK | — | +| `user_id` | BIGINT UNSIGNED | NOT NULL, INDEX | `users.id` yabancı anahtar | +| `provider` | VARCHAR(255) | NOT NULL | `google`, `github` vb. | +| `provider_id` | VARCHAR(255) | NOT NULL | Sağlayıcıdan gelen ID | +| `email` | VARCHAR(255) | — | Sağlayıcı e-postası | +| `name` | VARCHAR(255) | — | Tam ad | +| `avatar_url` | VARCHAR(255) | — | Profil fotoğrafı URL | +| `created_at` | DATETIME | — | — | +| `updated_at` | DATETIME | — | — | +| `deleted_at` | DATETIME | NULL | Soft-delete | + +--- + +### `profiles` + +| Sütun | Tip | Kısıtlama | Açıklama | +|---|---|---|---| +| `id` | BIGINT UNSIGNED | PK | — | +| `user_id` | BIGINT UNSIGNED | NOT NULL, INDEX | `users.id` yabancı anahtar | +| `avatar_url` | VARCHAR(255) | — | Profil fotoğrafı | +| `first_name` | VARCHAR(255) | — | Ad | +| `last_name` | VARCHAR(255) | — | Soyad | +| `created_at` | DATETIME | — | — | +| `updated_at` | DATETIME | — | — | +| `deleted_at` | DATETIME | NULL | Soft-delete | + +--- + +### `settings` + +Site genel ayarları. + +Örnek alanlar: +- `title`, `meta_title`, `meta_description` +- `phone`, `url`, `email` +- sosyal alanlar (`facebook`, `x`, `instagram`, ...) +- logo/medya alanları (`w_logo`, `b_logo`, `w_width`, ...) +- `is_active` + +### `heroes` + +Anasayfa hero/banner kayıtları: +- `color`, `title`, `text1/text2/text4/text5` +- `image`, `width`, `height`, `quality`, `format` +- `is_active` + +### `cors_whitelists` / `cors_blacklists` + +Dynamic CORS politika tabloları: +- whitelist: izinli origin +- blacklist: engelli origin (öncelikli blok) + +### `rate_limit_settings` + +DB tabanlı rate-limit kuralları: +- `name` (unique): ör. `api/v1/auth/login`, `api/v1/auth/register`, `api` +- `max_requests`, `window_seconds`, `is_active` + +### Shop tabloları + +- `product_categories` +- `product_tags` +- `products` +- join tabloları: `product_product_categories`, `product_product_tags` +- `product_category_views` +- `product_comments` +- `carts` +- `cart_items` + +### Blog tabloları + +- `categories` +- `tags` +- `posts` +- join tabloları: `post_categories`, `post_tags` +- `category_views` +- `comments` + +--- + +## İlişkiler + +``` +users (1) ──────── (N) social_accounts +users (1) ──────── (N) profiles +``` + +--- + +## AutoMigrate + +`config.ConnectDB()` her başlatmada çalışır ve eksik tabloları / sütunları ekler. +Mevcut sütunları silmez veya daraltmaz. + +```go +db.AutoMigrate( + &accountModels.User{}, + &accountModels.SocialAccount{}, + &accountModels.Profile{}, + &settingsModels.Setting{}, + &settingsModels.Hero{}, + &settingsModels.CorsWhitelist{}, + &settingsModels.CorsBlacklist{}, + &settingsModels.RateLimitSetting{}, + &shopModels.ProductCategory{}, + &shopModels.ProductTag{}, + &shopModels.Product{}, + &shopModels.ProductCategoryView{}, + &shopModels.ProductComment{}, + &shopModels.Cart{}, + &shopModels.CartItem{}, + &blogModels.Category{}, + &blogModels.Tag{}, + &blogModels.Post{}, + &blogModels.CategoryView{}, + &blogModels.Comment{}, +) +``` + +--- + +## Notlar + +- Tüm modellerde `gorm.Model` kullanıldığı için soft delete (`deleted_at`) aktiftir. +- CORS ve rate-limit için başlangıç seed kayıtları uygulama açılışında yalnızca **yoksa** oluşturulur. diff --git a/build_windows.sh b/build_windows.sh new file mode 100755 index 0000000..a5b25e0 --- /dev/null +++ b/build_windows.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Renk tanımlamaları +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # Renk Yok + +echo -e "${BLUE}=== Go Windows Build Script ===${NC}" + +# Çıktı klasörünü oluştur +OUTPUT_DIR="builds" +mkdir -p $OUTPUT_DIR + +# Ana dosya adı (main.go varsayılır, yoksa ilk argümanı al) +ENTRY_FILE=${1:-"main.go"} +APP_NAME=$(basename $(pwd)) + +if [ ! -f "$ENTRY_FILE" ]; then + echo -e "${RED}Hata: $ENTRY_FILE bulunamadı!${NC}" + exit 1 +fi + +echo -e "Derleniyor: ${GREEN}$ENTRY_FILE${NC}" + +# Mimari seçimi (Varsayılan amd64) +read -p "Mimari seçin (1: amd64 [Varsayılan], 2: 386): " ARCH_CHOICE + +if [[ "$ARCH_CHOICE" == "2" ]]; then + export GOARCH=386 + FINAL_NAME="${APP_NAME}_x86.exe" +else + export GOARCH=amd64 + FINAL_NAME="${APP_NAME}_x64.exe" +fi + +# Derleme işlemi +# -s -w bayrakları dosya boyutunu ciddi oranda küçültür +echo -e "${BLUE}Windows için derleniyor...${NC}" + +env GOOS=windows CGO_ENABLED=0 \ + go build -ldflags="-s -w" \ + -o "$OUTPUT_DIR/$FINAL_NAME" "$ENTRY_FILE" + +if [ $? -eq 0 ]; then + echo -e "${GREEN}Başarılı!${NC}" + echo -e "Çıktı: ${BLUE}$OUTPUT_DIR/$FINAL_NAME${NC}" +else + echo -e "${RED}Derleme sırasında bir hata oluştu.${NC}" +fi \ No newline at end of file diff --git a/builds/goaresv3_x64.exe b/builds/goaresv3_x64.exe new file mode 100755 index 0000000..c09b536 Binary files /dev/null and b/builds/goaresv3_x64.exe differ diff --git a/config/database.go b/config/database.go new file mode 100644 index 0000000..68a83f8 --- /dev/null +++ b/config/database.go @@ -0,0 +1,230 @@ +package config + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" + + accountModels "goaresv3/app/accounts/models" + blogModels "goaresv3/app/blog/models" + settingsModels "goaresv3/app/settings/models" + shopModels "goaresv3/app/shop/models" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var DB *gorm.DB + +// ConnectDB opens a MySQL connection via GORM. +func ConnectDB() error { + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), + os.Getenv("DB_HOST"), + os.Getenv("DB_PORT"), + os.Getenv("DB_NAME"), + ) + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return fmt.Errorf("database connection failed: %w", err) + } + + DB = db + return nil +} + +// RunAutoMigrate applies schema changes for account models. +func RunAutoMigrate() error { + if DB == nil { + return fmt.Errorf("database is not connected") + } + + if err := DB.AutoMigrate( + &accountModels.User{}, + &accountModels.SocialAccount{}, + &accountModels.Profile{}, + &settingsModels.Setting{}, + &settingsModels.Hero{}, + &settingsModels.CorsWhitelist{}, + &settingsModels.CorsBlacklist{}, + &settingsModels.RateLimitSetting{}, + &shopModels.ProductCategory{}, + &shopModels.ProductTag{}, + &shopModels.Product{}, + &shopModels.ProductCategoryView{}, + &shopModels.ProductComment{}, + &shopModels.Cart{}, + &shopModels.CartItem{}, + &blogModels.Category{}, + &blogModels.Tag{}, + &blogModels.Post{}, + &blogModels.CategoryView{}, + &blogModels.Comment{}, + ); err != nil { + return fmt.Errorf("auto-migrate failed: %w", err) + } + + return nil +} + +// SeedSecurityDefaults inserts known origins into CORS tables if missing. +// Existing rows are left untouched. +func SeedSecurityDefaults() error { + if DB == nil { + return fmt.Errorf("database is not connected") + } + + for _, origin := range bootstrapWhitelistOrigins() { + item := settingsModels.CorsWhitelist{ + Origin: origin, + IsActive: true, + } + if err := DB.Where("origin = ?", origin).FirstOrCreate(&item).Error; err != nil { + return fmt.Errorf("seed cors whitelist (%s): %w", origin, err) + } + } + + for _, origin := range parseOriginList(os.Getenv("CORS_BOOTSTRAP_BLACKLIST_ORIGINS")) { + item := settingsModels.CorsBlacklist{ + Origin: origin, + IsActive: true, + } + if err := DB.Where("origin = ?", origin).FirstOrCreate(&item).Error; err != nil { + return fmt.Errorf("seed cors blacklist (%s): %w", origin, err) + } + } + + for _, rule := range bootstrapRateLimitRules() { + item := settingsModels.RateLimitSetting{ + Name: rule.Name, + Description: rule.Description, + MaxRequests: rule.MaxRequests, + WindowSeconds: rule.WindowSeconds, + IsActive: true, + } + if err := DB.Where("name = ?", rule.Name).FirstOrCreate(&item).Error; err != nil { + return fmt.Errorf("seed rate limit (%s): %w", rule.Name, err) + } + } + + return nil +} + +type rateLimitSeedRule struct { + Name string + Description string + MaxRequests int64 + WindowSeconds int +} + +func bootstrapRateLimitRules() []rateLimitSeedRule { + return []rateLimitSeedRule{ + { + Name: "api/v1/auth/login", + Description: "Bootstrap login rate limit", + MaxRequests: envInt64Or("RL_BOOTSTRAP_LOGIN_MAX_REQUESTS", 10), + WindowSeconds: envIntOr("RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS", 60), + }, + { + Name: "api/v1/auth/register", + Description: "Bootstrap register rate limit", + MaxRequests: envInt64Or("RL_BOOTSTRAP_REGISTER_MAX_REQUESTS", 5), + WindowSeconds: envIntOr("RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS", 60), + }, + { + Name: "api", + Description: "Bootstrap default API rate limit", + MaxRequests: envInt64Or("RL_BOOTSTRAP_API_MAX_REQUESTS", 120), + WindowSeconds: envIntOr("RL_BOOTSTRAP_API_WINDOW_SECONDS", 60), + }, + } +} + +func bootstrapWhitelistOrigins() []string { + uniq := map[string]struct{}{} + var out []string + add := func(v string) { + origin := normalizeOrigin(v) + if origin == "" { + return + } + if _, ok := uniq[origin]; ok { + return + } + uniq[origin] = struct{}{} + out = append(out, origin) + } + + // Explicit bootstrap origins from env (comma separated). + for _, v := range parseOriginList(os.Getenv("CORS_BOOTSTRAP_WHITELIST_ORIGINS")) { + add(v) + } + + // Derive common origins from existing app URLs. + add(os.Getenv("APP_BASE_URL")) + add(os.Getenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL")) + add(os.Getenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL")) + + // Safe local defaults for development. + add("http://localhost:8080") + add("http://localhost:3000") + add("http://localhost:5173") + add("http://127.0.0.1:8080") + add("http://127.0.0.1:3000") + add("http://127.0.0.1:5173") + + return out +} + +func parseOriginList(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + v := normalizeOrigin(p) + if v != "" { + out = append(out, v) + } + } + return out +} + +func normalizeOrigin(v string) string { + s := strings.TrimSpace(strings.Trim(v, `'"`)) + if s == "" { + return "" + } + + u, err := url.Parse(s) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + return strings.ToLower(u.Scheme + "://" + u.Host) +} + +func envIntOr(key string, fallback int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return fallback + } + return n +} + +func envInt64Or(key string, fallback int64) int64 { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 1 { + return fallback + } + return n +} diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..770a4e4 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,3498 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/auth/github/callback": { + "get": { + "description": "Exchanges GitHub code and returns local access/refresh tokens.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "GitHub OAuth callback", + "parameters": [ + { + "type": "string", + "description": "oauth state", + "name": "state", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "authorization code", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/github/login": { + "get": { + "description": "Returns GitHub authorization URL and sets state cookie for CSRF protection.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Start GitHub OAuth login", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/google/callback": { + "get": { + "description": "Exchanges Google code and returns local access/refresh tokens.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Google OAuth callback", + "parameters": [ + { + "type": "string", + "description": "oauth state", + "name": "state", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "authorization code", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/google/login": { + "get": { + "description": "Returns Google authorization URL and sets state cookie for CSRF protection.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Start Google OAuth login", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "description": "Returns access and refresh tokens.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login with email/password", + "parameters": [ + { + "description": "login payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "Exchanges a valid refresh token for a new access token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "refresh payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Creates a user and sends an email verification link.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "register payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "description": "Activates account using email verification token.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Verify email address", + "parameters": [ + { + "type": "string", + "description": "email verification token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/categories": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "List blog categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Create blog category", + "parameters": [ + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Update blog category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Delete blog category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/posts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "List blog posts", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Create blog post", + "parameters": [ + { + "description": "post payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertPostRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/blog/posts/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Get blog post", + "parameters": [ + { + "type": "integer", + "description": "post id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Update blog post", + "parameters": [ + { + "type": "integer", + "description": "post id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "post payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertPostRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Delete blog post", + "parameters": [ + { + "type": "integer", + "description": "post id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/tags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "List blog tags", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Create blog tag", + "parameters": [ + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/blog/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Update blog tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Delete blog tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns user_id, email and username from the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Get current user info", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings": { + "get": { + "description": "Returns the latest settings record.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Get global setting", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates the first setting record if none exists, otherwise updates the latest one.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create or update global setting", + "parameters": [ + { + "description": "setting payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertSettingRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/blacklist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns all CORS blacklist records.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List CORS blacklist items", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new blacklist origin.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create CORS blacklist item", + "parameters": [ + { + "description": "cors blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsBlacklistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/blacklist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a blacklist origin by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update CORS blacklist item", + "parameters": [ + { + "type": "integer", + "description": "blacklist id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "cors blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsBlacklistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a blacklist origin by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete CORS blacklist item", + "parameters": [ + { + "type": "integer", + "description": "blacklist id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/whitelist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns all CORS whitelist records.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List CORS whitelist items", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new whitelist origin.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create CORS whitelist item", + "parameters": [ + { + "description": "cors whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsWhitelistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/whitelist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a whitelist origin by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update CORS whitelist item", + "parameters": [ + { + "type": "integer", + "description": "whitelist id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "cors whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsWhitelistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a whitelist origin by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete CORS whitelist item", + "parameters": [ + { + "type": "integer", + "description": "whitelist id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/heroes": { + "get": { + "description": "Returns all hero records ordered by id desc.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List heroes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new hero record.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create hero", + "parameters": [ + { + "description": "hero payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertHeroRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/heroes/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a hero by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update hero", + "parameters": [ + { + "type": "integer", + "description": "hero id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "hero payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertHeroRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a hero by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete hero", + "parameters": [ + { + "type": "integer", + "description": "hero id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/rate-limits": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns all rate-limit settings.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List rate limits", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new rate-limit setting.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create rate limit", + "parameters": [ + { + "description": "rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertRateLimitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/rate-limits/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a rate-limit setting by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update rate limit", + "parameters": [ + { + "type": "integer", + "description": "rate limit id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertRateLimitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a rate-limit setting by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete rate limit", + "parameters": [ + { + "type": "integer", + "description": "rate limit id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/cart": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the authenticated user's cart with items.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Get my cart", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/cart/items": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates or increments a cart item for authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Add item to my cart", + "parameters": [ + { + "description": "cart item payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCartItemRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/cart/items/{itemId}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a cart item owned by authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update my cart item", + "parameters": [ + { + "type": "integer", + "description": "cart item id", + "name": "itemId", + "in": "path", + "required": true + }, + { + "description": "cart item payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCartItemRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a cart item owned by authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete item from my cart", + "parameters": [ + { + "type": "integer", + "description": "cart item id", + "name": "itemId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/categories": { + "get": { + "description": "Returns all categories with children.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "List product categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new shop category.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Create product category", + "parameters": [ + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a category by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update product category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a category by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete product category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/products": { + "get": { + "description": "Returns all products with categories and tags.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "List products", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new product and assigns category/tag relations.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Create product", + "parameters": [ + { + "description": "product payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/products/{id}": { + "get": { + "description": "Returns product details by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Get product", + "parameters": [ + { + "type": "integer", + "description": "product id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a product and reassigns category/tag relations.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update product", + "parameters": [ + { + "type": "integer", + "description": "product id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "product payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a product by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete product", + "parameters": [ + { + "type": "integer", + "description": "product id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/tags": { + "get": { + "description": "Returns all product tags.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "List product tags", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new product tag.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Create product tag", + "parameters": [ + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a tag by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update product tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a tag by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete product tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "controllers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "controllers.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "controllers.RegisterRequest": { + "type": "object", + "required": [ + "confirm_password", + "email", + "password", + "username" + ], + "properties": { + "confirm_password": { + "type": "string", + "minLength": 8 + }, + "email": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 50, + "minLength": 3 + } + } + }, + "controllers.UpsertCartItemRequest": { + "type": "object", + "required": [ + "product_id", + "quantity" + ], + "properties": { + "product_id": { + "type": "integer" + }, + "quantity": { + "type": "integer", + "minimum": 1 + } + } + }, + "controllers.UpsertCategoryRequest": { + "type": "object", + "required": [ + "slug", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertCorsBlacklistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "created_by": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string", + "maxLength": 255 + }, + "reason": { + "type": "string", + "maxLength": 255 + } + } + }, + "controllers.UpsertCorsWhitelistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "created_by": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string", + "maxLength": 255 + } + } + }, + "controllers.UpsertHeroRequest": { + "type": "object", + "required": [ + "color" + ], + "properties": { + "color": { + "type": "string", + "maxLength": 32 + }, + "format": { + "type": "string", + "maxLength": 10 + }, + "height": { + "type": "integer" + }, + "image": { + "type": "string", + "maxLength": 254 + }, + "is_active": { + "type": "boolean" + }, + "quality": { + "type": "integer" + }, + "text1": { + "type": "string", + "maxLength": 254 + }, + "text2": { + "type": "string", + "maxLength": 254 + }, + "text4": { + "type": "string", + "maxLength": 254 + }, + "text5": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "width": { + "type": "integer" + } + } + }, + "controllers.UpsertPostRequest": { + "type": "object", + "required": [ + "images", + "images_mid", + "images_min", + "slug", + "title" + ], + "properties": { + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "format": { + "type": "string", + "maxLength": 10 + }, + "height": { + "type": "integer" + }, + "images": { + "type": "string" + }, + "images_mid": { + "type": "string" + }, + "images_min": { + "type": "string" + }, + "quality": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "tag_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "width": { + "type": "integer" + } + } + }, + "controllers.UpsertProductCategoryRequest": { + "type": "object", + "required": [ + "slug", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertProductRequest": { + "type": "object", + "required": [ + "images", + "slug", + "title" + ], + "properties": { + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "format": { + "type": "string", + "maxLength": 10 + }, + "height": { + "type": "integer" + }, + "images": { + "type": "string" + }, + "price": { + "type": "number" + }, + "quality": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "tag_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "width": { + "type": "integer" + } + } + }, + "controllers.UpsertProductTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertRateLimitRequest": { + "type": "object", + "required": [ + "max_requests", + "name", + "window_seconds" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "max_requests": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "updated_by": { + "type": "string", + "maxLength": 255 + }, + "window_seconds": { + "type": "integer", + "minimum": 1 + } + } + }, + "controllers.UpsertSettingRequest": { + "type": "object", + "required": [ + "email", + "meta_description", + "meta_title", + "phone", + "title", + "url" + ], + "properties": { + "address": { + "type": "string" + }, + "b_format": { + "type": "string", + "maxLength": 10 + }, + "b_height": { + "type": "integer" + }, + "b_logo": { + "type": "string" + }, + "b_quality": { + "type": "integer" + }, + "b_width": { + "type": "integer" + }, + "copyright": { + "type": "string", + "maxLength": 254 + }, + "email": { + "type": "string", + "maxLength": 254 + }, + "facebook": { + "type": "string", + "maxLength": 254 + }, + "instagram": { + "type": "string", + "maxLength": 254 + }, + "is_active": { + "type": "boolean" + }, + "linkedin": { + "type": "string", + "maxLength": 254 + }, + "map_embed": { + "type": "string" + }, + "meta_description": { + "type": "string", + "maxLength": 254 + }, + "meta_title": { + "type": "string", + "maxLength": 254 + }, + "phone": { + "type": "string", + "maxLength": 254 + }, + "pinterest": { + "type": "string", + "maxLength": 254 + }, + "slogan": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "url": { + "type": "string", + "maxLength": 254 + }, + "w_format": { + "type": "string", + "maxLength": 10 + }, + "w_height": { + "type": "integer" + }, + "w_logo": { + "type": "string" + }, + "w_quality": { + "type": "integer" + }, + "w_width": { + "type": "integer" + }, + "whatsapp": { + "type": "string", + "maxLength": 254 + }, + "x": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 254 + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Paste the access token. Swagger UI sends it as: Bearer \u003ctoken\u003e", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{"http", "https"}, + Title: "goaresv3 API", + Description: "Authentication API for goaresv3.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..fc6f984 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,3477 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "description": "Authentication API for goaresv3.", + "title": "goaresv3 API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/v1/auth/github/callback": { + "get": { + "description": "Exchanges GitHub code and returns local access/refresh tokens.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "GitHub OAuth callback", + "parameters": [ + { + "type": "string", + "description": "oauth state", + "name": "state", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "authorization code", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/github/login": { + "get": { + "description": "Returns GitHub authorization URL and sets state cookie for CSRF protection.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Start GitHub OAuth login", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/google/callback": { + "get": { + "description": "Exchanges Google code and returns local access/refresh tokens.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Google OAuth callback", + "parameters": [ + { + "type": "string", + "description": "oauth state", + "name": "state", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "authorization code", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/google/login": { + "get": { + "description": "Returns Google authorization URL and sets state cookie for CSRF protection.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Start Google OAuth login", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "description": "Returns access and refresh tokens.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login with email/password", + "parameters": [ + { + "description": "login payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "Exchanges a valid refresh token for a new access token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "refresh payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Creates a user and sends an email verification link.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "register payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "description": "Activates account using email verification token.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Verify email address", + "parameters": [ + { + "type": "string", + "description": "email verification token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/categories": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "List blog categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Create blog category", + "parameters": [ + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Update blog category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Delete blog category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/posts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "List blog posts", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Create blog post", + "parameters": [ + { + "description": "post payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertPostRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/blog/posts/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Get blog post", + "parameters": [ + { + "type": "integer", + "description": "post id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Update blog post", + "parameters": [ + { + "type": "integer", + "description": "post id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "post payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertPostRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Delete blog post", + "parameters": [ + { + "type": "integer", + "description": "post id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/blog/tags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "List blog tags", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Create blog tag", + "parameters": [ + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/blog/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Update blog tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Blog" + ], + "summary": "Delete blog tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns user_id, email and username from the authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Get current user info", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings": { + "get": { + "description": "Returns the latest settings record.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Get global setting", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates the first setting record if none exists, otherwise updates the latest one.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create or update global setting", + "parameters": [ + { + "description": "setting payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertSettingRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/blacklist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns all CORS blacklist records.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List CORS blacklist items", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new blacklist origin.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create CORS blacklist item", + "parameters": [ + { + "description": "cors blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsBlacklistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/blacklist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a blacklist origin by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update CORS blacklist item", + "parameters": [ + { + "type": "integer", + "description": "blacklist id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "cors blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsBlacklistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a blacklist origin by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete CORS blacklist item", + "parameters": [ + { + "type": "integer", + "description": "blacklist id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/whitelist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns all CORS whitelist records.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List CORS whitelist items", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new whitelist origin.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create CORS whitelist item", + "parameters": [ + { + "description": "cors whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsWhitelistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/cors/whitelist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a whitelist origin by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update CORS whitelist item", + "parameters": [ + { + "type": "integer", + "description": "whitelist id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "cors whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCorsWhitelistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a whitelist origin by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete CORS whitelist item", + "parameters": [ + { + "type": "integer", + "description": "whitelist id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/heroes": { + "get": { + "description": "Returns all hero records ordered by id desc.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List heroes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new hero record.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create hero", + "parameters": [ + { + "description": "hero payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertHeroRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/heroes/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a hero by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update hero", + "parameters": [ + { + "type": "integer", + "description": "hero id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "hero payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertHeroRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a hero by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete hero", + "parameters": [ + { + "type": "integer", + "description": "hero id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/rate-limits": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns all rate-limit settings.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "List rate limits", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new rate-limit setting.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Create rate limit", + "parameters": [ + { + "description": "rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertRateLimitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings/rate-limits/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a rate-limit setting by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update rate limit", + "parameters": [ + { + "type": "integer", + "description": "rate limit id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertRateLimitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a rate-limit setting by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Delete rate limit", + "parameters": [ + { + "type": "integer", + "description": "rate limit id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/cart": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the authenticated user's cart with items.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Get my cart", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/cart/items": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates or increments a cart item for authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Add item to my cart", + "parameters": [ + { + "description": "cart item payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCartItemRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/cart/items/{itemId}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a cart item owned by authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update my cart item", + "parameters": [ + { + "type": "integer", + "description": "cart item id", + "name": "itemId", + "in": "path", + "required": true + }, + { + "description": "cart item payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertCartItemRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a cart item owned by authenticated user.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete item from my cart", + "parameters": [ + { + "type": "integer", + "description": "cart item id", + "name": "itemId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/categories": { + "get": { + "description": "Returns all categories with children.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "List product categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new shop category.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Create product category", + "parameters": [ + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a category by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update product category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "category payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a category by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete product category", + "parameters": [ + { + "type": "integer", + "description": "category id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/products": { + "get": { + "description": "Returns all products with categories and tags.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "List products", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new product and assigns category/tag relations.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Create product", + "parameters": [ + { + "description": "product payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/products/{id}": { + "get": { + "description": "Returns product details by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Get product", + "parameters": [ + { + "type": "integer", + "description": "product id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a product and reassigns category/tag relations.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update product", + "parameters": [ + { + "type": "integer", + "description": "product id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "product payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a product by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete product", + "parameters": [ + { + "type": "integer", + "description": "product id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/tags": { + "get": { + "description": "Returns all product tags.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "List product tags", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new product tag.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Create product tag", + "parameters": [ + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/shop/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates a tag by id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Update product tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "tag payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpsertProductTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a tag by id.", + "produces": [ + "application/json" + ], + "tags": [ + "Shop" + ], + "summary": "Delete product tag", + "parameters": [ + { + "type": "integer", + "description": "tag id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "controllers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "controllers.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "controllers.RegisterRequest": { + "type": "object", + "required": [ + "confirm_password", + "email", + "password", + "username" + ], + "properties": { + "confirm_password": { + "type": "string", + "minLength": 8 + }, + "email": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 50, + "minLength": 3 + } + } + }, + "controllers.UpsertCartItemRequest": { + "type": "object", + "required": [ + "product_id", + "quantity" + ], + "properties": { + "product_id": { + "type": "integer" + }, + "quantity": { + "type": "integer", + "minimum": 1 + } + } + }, + "controllers.UpsertCategoryRequest": { + "type": "object", + "required": [ + "slug", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertCorsBlacklistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "created_by": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string", + "maxLength": 255 + }, + "reason": { + "type": "string", + "maxLength": 255 + } + } + }, + "controllers.UpsertCorsWhitelistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "created_by": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string", + "maxLength": 255 + } + } + }, + "controllers.UpsertHeroRequest": { + "type": "object", + "required": [ + "color" + ], + "properties": { + "color": { + "type": "string", + "maxLength": 32 + }, + "format": { + "type": "string", + "maxLength": 10 + }, + "height": { + "type": "integer" + }, + "image": { + "type": "string", + "maxLength": 254 + }, + "is_active": { + "type": "boolean" + }, + "quality": { + "type": "integer" + }, + "text1": { + "type": "string", + "maxLength": 254 + }, + "text2": { + "type": "string", + "maxLength": 254 + }, + "text4": { + "type": "string", + "maxLength": 254 + }, + "text5": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "width": { + "type": "integer" + } + } + }, + "controllers.UpsertPostRequest": { + "type": "object", + "required": [ + "images", + "images_mid", + "images_min", + "slug", + "title" + ], + "properties": { + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "format": { + "type": "string", + "maxLength": 10 + }, + "height": { + "type": "integer" + }, + "images": { + "type": "string" + }, + "images_mid": { + "type": "string" + }, + "images_min": { + "type": "string" + }, + "quality": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "tag_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "width": { + "type": "integer" + } + } + }, + "controllers.UpsertProductCategoryRequest": { + "type": "object", + "required": [ + "slug", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertProductRequest": { + "type": "object", + "required": [ + "images", + "slug", + "title" + ], + "properties": { + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "format": { + "type": "string", + "maxLength": 10 + }, + "height": { + "type": "integer" + }, + "images": { + "type": "string" + }, + "price": { + "type": "number" + }, + "quality": { + "type": "integer" + }, + "slug": { + "type": "string", + "maxLength": 254 + }, + "tag_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "width": { + "type": "integer" + } + } + }, + "controllers.UpsertProductTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertRateLimitRequest": { + "type": "object", + "required": [ + "max_requests", + "name", + "window_seconds" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "max_requests": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "updated_by": { + "type": "string", + "maxLength": 255 + }, + "window_seconds": { + "type": "integer", + "minimum": 1 + } + } + }, + "controllers.UpsertSettingRequest": { + "type": "object", + "required": [ + "email", + "meta_description", + "meta_title", + "phone", + "title", + "url" + ], + "properties": { + "address": { + "type": "string" + }, + "b_format": { + "type": "string", + "maxLength": 10 + }, + "b_height": { + "type": "integer" + }, + "b_logo": { + "type": "string" + }, + "b_quality": { + "type": "integer" + }, + "b_width": { + "type": "integer" + }, + "copyright": { + "type": "string", + "maxLength": 254 + }, + "email": { + "type": "string", + "maxLength": 254 + }, + "facebook": { + "type": "string", + "maxLength": 254 + }, + "instagram": { + "type": "string", + "maxLength": 254 + }, + "is_active": { + "type": "boolean" + }, + "linkedin": { + "type": "string", + "maxLength": 254 + }, + "map_embed": { + "type": "string" + }, + "meta_description": { + "type": "string", + "maxLength": 254 + }, + "meta_title": { + "type": "string", + "maxLength": 254 + }, + "phone": { + "type": "string", + "maxLength": 254 + }, + "pinterest": { + "type": "string", + "maxLength": 254 + }, + "slogan": { + "type": "string", + "maxLength": 254 + }, + "title": { + "type": "string", + "maxLength": 254 + }, + "url": { + "type": "string", + "maxLength": 254 + }, + "w_format": { + "type": "string", + "maxLength": 10 + }, + "w_height": { + "type": "integer" + }, + "w_logo": { + "type": "string" + }, + "w_quality": { + "type": "integer" + }, + "w_width": { + "type": "integer" + }, + "whatsapp": { + "type": "string", + "maxLength": 254 + }, + "x": { + "type": "string", + "maxLength": 254 + } + } + }, + "controllers.UpsertTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 254 + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Paste the access token. Swagger UI sends it as: Bearer \u003ctoken\u003e", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..f343109 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,2285 @@ +basePath: / +definitions: + controllers.LoginRequest: + properties: + email: + type: string + password: + type: string + required: + - email + - password + type: object + controllers.RefreshRequest: + properties: + refresh_token: + type: string + required: + - refresh_token + type: object + controllers.RegisterRequest: + properties: + confirm_password: + minLength: 8 + type: string + email: + type: string + password: + minLength: 8 + type: string + username: + maxLength: 50 + minLength: 3 + type: string + required: + - confirm_password + - email + - password + - username + type: object + controllers.UpsertCartItemRequest: + properties: + product_id: + type: integer + quantity: + minimum: 1 + type: integer + required: + - product_id + - quantity + type: object + controllers.UpsertCategoryRequest: + properties: + description: + type: string + parent_id: + type: integer + slug: + maxLength: 254 + type: string + title: + maxLength: 254 + type: string + required: + - slug + - title + type: object + controllers.UpsertCorsBlacklistRequest: + properties: + created_by: + maxLength: 255 + type: string + is_active: + type: boolean + origin: + maxLength: 255 + type: string + reason: + maxLength: 255 + type: string + required: + - origin + type: object + controllers.UpsertCorsWhitelistRequest: + properties: + created_by: + maxLength: 255 + type: string + description: + maxLength: 255 + type: string + is_active: + type: boolean + origin: + maxLength: 255 + type: string + required: + - origin + type: object + controllers.UpsertHeroRequest: + properties: + color: + maxLength: 32 + type: string + format: + maxLength: 10 + type: string + height: + type: integer + image: + maxLength: 254 + type: string + is_active: + type: boolean + quality: + type: integer + text1: + maxLength: 254 + type: string + text2: + maxLength: 254 + type: string + text4: + maxLength: 254 + type: string + text5: + maxLength: 254 + type: string + title: + maxLength: 254 + type: string + width: + type: integer + required: + - color + type: object + controllers.UpsertPostRequest: + properties: + category_ids: + items: + type: integer + type: array + content: + type: string + format: + maxLength: 10 + type: string + height: + type: integer + images: + type: string + images_mid: + type: string + images_min: + type: string + quality: + type: integer + slug: + maxLength: 254 + type: string + tag_ids: + items: + type: integer + type: array + title: + maxLength: 254 + type: string + width: + type: integer + required: + - images + - images_mid + - images_min + - slug + - title + type: object + controllers.UpsertProductCategoryRequest: + properties: + description: + type: string + keywords: + type: string + parent_id: + type: integer + slug: + maxLength: 254 + type: string + title: + maxLength: 254 + type: string + required: + - slug + - title + type: object + controllers.UpsertProductRequest: + properties: + category_ids: + items: + type: integer + type: array + content: + type: string + format: + maxLength: 10 + type: string + height: + type: integer + images: + type: string + price: + type: number + quality: + type: integer + slug: + maxLength: 254 + type: string + tag_ids: + items: + type: integer + type: array + title: + maxLength: 254 + type: string + width: + type: integer + required: + - images + - slug + - title + type: object + controllers.UpsertProductTagRequest: + properties: + name: + maxLength: 254 + type: string + required: + - name + type: object + controllers.UpsertRateLimitRequest: + properties: + description: + maxLength: 255 + type: string + is_active: + type: boolean + max_requests: + minimum: 1 + type: integer + name: + maxLength: 100 + type: string + updated_by: + maxLength: 255 + type: string + window_seconds: + minimum: 1 + type: integer + required: + - max_requests + - name + - window_seconds + type: object + controllers.UpsertSettingRequest: + properties: + address: + type: string + b_format: + maxLength: 10 + type: string + b_height: + type: integer + b_logo: + type: string + b_quality: + type: integer + b_width: + type: integer + copyright: + maxLength: 254 + type: string + email: + maxLength: 254 + type: string + facebook: + maxLength: 254 + type: string + instagram: + maxLength: 254 + type: string + is_active: + type: boolean + linkedin: + maxLength: 254 + type: string + map_embed: + type: string + meta_description: + maxLength: 254 + type: string + meta_title: + maxLength: 254 + type: string + phone: + maxLength: 254 + type: string + pinterest: + maxLength: 254 + type: string + slogan: + maxLength: 254 + type: string + title: + maxLength: 254 + type: string + url: + maxLength: 254 + type: string + w_format: + maxLength: 10 + type: string + w_height: + type: integer + w_logo: + type: string + w_quality: + type: integer + w_width: + type: integer + whatsapp: + maxLength: 254 + type: string + x: + maxLength: 254 + type: string + required: + - email + - meta_description + - meta_title + - phone + - title + - url + type: object + controllers.UpsertTagRequest: + properties: + name: + maxLength: 254 + type: string + required: + - name + type: object +info: + contact: {} + description: Authentication API for goaresv3. + title: goaresv3 API + version: "1.0" +paths: + /api/v1/auth/github/callback: + get: + description: Exchanges GitHub code and returns local access/refresh tokens. + parameters: + - description: oauth state + in: query + name: state + required: true + type: string + - description: authorization code + in: query + name: code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: GitHub OAuth callback + tags: + - Auth + /api/v1/auth/github/login: + get: + description: Returns GitHub authorization URL and sets state cookie for CSRF + protection. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Start GitHub OAuth login + tags: + - Auth + /api/v1/auth/google/callback: + get: + description: Exchanges Google code and returns local access/refresh tokens. + parameters: + - description: oauth state + in: query + name: state + required: true + type: string + - description: authorization code + in: query + name: code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Google OAuth callback + tags: + - Auth + /api/v1/auth/google/login: + get: + description: Returns Google authorization URL and sets state cookie for CSRF + protection. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Start Google OAuth login + tags: + - Auth + /api/v1/auth/login: + post: + consumes: + - application/json + description: Returns access and refresh tokens. + parameters: + - description: login payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Login with email/password + tags: + - Auth + /api/v1/auth/refresh: + post: + consumes: + - application/json + description: Exchanges a valid refresh token for a new access token. + parameters: + - description: refresh payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Refresh access token + tags: + - Auth + /api/v1/auth/register: + post: + consumes: + - application/json + description: Creates a user and sends an email verification link. + parameters: + - description: register payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + summary: Register a new user + tags: + - Auth + /api/v1/auth/verify-email: + get: + description: Activates account using email verification token. + parameters: + - description: email verification token + in: query + name: token + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Verify email address + tags: + - Auth + /api/v1/blog/categories: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List blog categories + tags: + - Blog + post: + consumes: + - application/json + parameters: + - description: category payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCategoryRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create blog category + tags: + - Blog + /api/v1/blog/categories/{id}: + delete: + parameters: + - description: category id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete blog category + tags: + - Blog + put: + consumes: + - application/json + parameters: + - description: category id + in: path + name: id + required: true + type: integer + - description: category payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCategoryRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update blog category + tags: + - Blog + /api/v1/blog/posts: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + summary: List blog posts + tags: + - Blog + post: + consumes: + - application/json + parameters: + - description: post payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertPostRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Create blog post + tags: + - Blog + /api/v1/blog/posts/{id}: + delete: + parameters: + - description: post id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete blog post + tags: + - Blog + get: + parameters: + - description: post id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get blog post + tags: + - Blog + put: + consumes: + - application/json + parameters: + - description: post id + in: path + name: id + required: true + type: integer + - description: post payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertPostRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Update blog post + tags: + - Blog + /api/v1/blog/tags: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + summary: List blog tags + tags: + - Blog + post: + consumes: + - application/json + parameters: + - description: tag payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertTagRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Create blog tag + tags: + - Blog + /api/v1/blog/tags/{id}: + delete: + parameters: + - description: tag id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete blog tag + tags: + - Blog + put: + consumes: + - application/json + parameters: + - description: tag id + in: path + name: id + required: true + type: integer + - description: tag payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertTagRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Update blog tag + tags: + - Blog + /api/v1/me: + get: + description: Returns user_id, email and username from the authenticated user. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get current user info + tags: + - User + /api/v1/settings: + get: + description: Returns the latest settings record. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get global setting + tags: + - Settings + put: + consumes: + - application/json + description: Creates the first setting record if none exists, otherwise updates + the latest one. + parameters: + - description: setting payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertSettingRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create or update global setting + tags: + - Settings + /api/v1/settings/cors/blacklist: + get: + description: Returns all CORS blacklist records. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List CORS blacklist items + tags: + - Settings + post: + consumes: + - application/json + description: Creates a new blacklist origin. + parameters: + - description: cors blacklist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCorsBlacklistRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create CORS blacklist item + tags: + - Settings + /api/v1/settings/cors/blacklist/{id}: + delete: + description: Deletes a blacklist origin by id. + parameters: + - description: blacklist id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete CORS blacklist item + tags: + - Settings + put: + consumes: + - application/json + description: Updates a blacklist origin by id. + parameters: + - description: blacklist id + in: path + name: id + required: true + type: integer + - description: cors blacklist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCorsBlacklistRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update CORS blacklist item + tags: + - Settings + /api/v1/settings/cors/whitelist: + get: + description: Returns all CORS whitelist records. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List CORS whitelist items + tags: + - Settings + post: + consumes: + - application/json + description: Creates a new whitelist origin. + parameters: + - description: cors whitelist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCorsWhitelistRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create CORS whitelist item + tags: + - Settings + /api/v1/settings/cors/whitelist/{id}: + delete: + description: Deletes a whitelist origin by id. + parameters: + - description: whitelist id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete CORS whitelist item + tags: + - Settings + put: + consumes: + - application/json + description: Updates a whitelist origin by id. + parameters: + - description: whitelist id + in: path + name: id + required: true + type: integer + - description: cors whitelist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCorsWhitelistRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update CORS whitelist item + tags: + - Settings + /api/v1/settings/heroes: + get: + description: Returns all hero records ordered by id desc. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List heroes + tags: + - Settings + post: + consumes: + - application/json + description: Creates a new hero record. + parameters: + - description: hero payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertHeroRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create hero + tags: + - Settings + /api/v1/settings/heroes/{id}: + delete: + description: Deletes a hero by id. + parameters: + - description: hero id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete hero + tags: + - Settings + put: + consumes: + - application/json + description: Updates a hero by id. + parameters: + - description: hero id + in: path + name: id + required: true + type: integer + - description: hero payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertHeroRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update hero + tags: + - Settings + /api/v1/settings/rate-limits: + get: + description: Returns all rate-limit settings. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List rate limits + tags: + - Settings + post: + consumes: + - application/json + description: Creates a new rate-limit setting. + parameters: + - description: rate limit payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertRateLimitRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create rate limit + tags: + - Settings + /api/v1/settings/rate-limits/{id}: + delete: + description: Deletes a rate-limit setting by id. + parameters: + - description: rate limit id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete rate limit + tags: + - Settings + put: + consumes: + - application/json + description: Updates a rate-limit setting by id. + parameters: + - description: rate limit id + in: path + name: id + required: true + type: integer + - description: rate limit payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertRateLimitRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update rate limit + tags: + - Settings + /api/v1/shop/cart: + get: + description: Returns the authenticated user's cart with items. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get my cart + tags: + - Shop + /api/v1/shop/cart/items: + post: + consumes: + - application/json + description: Creates or increments a cart item for authenticated user. + parameters: + - description: cart item payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCartItemRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Add item to my cart + tags: + - Shop + /api/v1/shop/cart/items/{itemId}: + delete: + description: Deletes a cart item owned by authenticated user. + parameters: + - description: cart item id + in: path + name: itemId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete item from my cart + tags: + - Shop + put: + consumes: + - application/json + description: Updates a cart item owned by authenticated user. + parameters: + - description: cart item id + in: path + name: itemId + required: true + type: integer + - description: cart item payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertCartItemRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update my cart item + tags: + - Shop + /api/v1/shop/categories: + get: + description: Returns all categories with children. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List product categories + tags: + - Shop + post: + consumes: + - application/json + description: Creates a new shop category. + parameters: + - description: category payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertProductCategoryRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create product category + tags: + - Shop + /api/v1/shop/categories/{id}: + delete: + description: Deletes a category by id. + parameters: + - description: category id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete product category + tags: + - Shop + put: + consumes: + - application/json + description: Updates a category by id. + parameters: + - description: category id + in: path + name: id + required: true + type: integer + - description: category payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertProductCategoryRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update product category + tags: + - Shop + /api/v1/shop/products: + get: + description: Returns all products with categories and tags. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List products + tags: + - Shop + post: + consumes: + - application/json + description: Creates a new product and assigns category/tag relations. + parameters: + - description: product payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertProductRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create product + tags: + - Shop + /api/v1/shop/products/{id}: + delete: + description: Deletes a product by id. + parameters: + - description: product id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete product + tags: + - Shop + get: + description: Returns product details by id. + parameters: + - description: product id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get product + tags: + - Shop + put: + consumes: + - application/json + description: Updates a product and reassigns category/tag relations. + parameters: + - description: product id + in: path + name: id + required: true + type: integer + - description: product payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertProductRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update product + tags: + - Shop + /api/v1/shop/tags: + get: + description: Returns all product tags. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List product tags + tags: + - Shop + post: + consumes: + - application/json + description: Creates a new product tag. + parameters: + - description: tag payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertProductTagRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create product tag + tags: + - Shop + /api/v1/shop/tags/{id}: + delete: + description: Deletes a tag by id. + parameters: + - description: tag id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete product tag + tags: + - Shop + put: + consumes: + - application/json + description: Updates a tag by id. + parameters: + - description: tag id + in: path + name: id + required: true + type: integer + - description: tag payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.UpsertProductTagRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update product tag + tags: + - Shop +schemes: +- http +- https +securityDefinitions: + BearerAuth: + description: 'Paste the access token. Swagger UI sends it as: Bearer ' + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/env.zip b/env.zip new file mode 100644 index 0000000..8461935 Binary files /dev/null and b/env.zip differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cbf66c2 --- /dev/null +++ b/go.mod @@ -0,0 +1,67 @@ +module goaresv3 + +go 1.26.1 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.48.0 + golang.org/x/oauth2 v0.36.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + filippo.io/edwards25519 v1.1.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/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.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.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // 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/mattn/go-sqlite3 v1.14.22 // 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/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cef01f2 --- /dev/null +++ b/go.sum @@ -0,0 +1,199 @@ +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= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/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/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/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/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/goaresv3 b/goaresv3 new file mode 100755 index 0000000..a775360 Binary files /dev/null and b/goaresv3 differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..ea4d019 --- /dev/null +++ b/main.go @@ -0,0 +1,57 @@ +// @title goaresv3 API +// @version 1.0 +// @description Authentication API for goaresv3. +// @BasePath / +// @schemes http https +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Paste the access token. Swagger UI sends it as: Bearer +package main + +import ( + "log" + "os" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + + "goaresv3/config" + _ "goaresv3/docs" + "goaresv3/pkg/middleware" + "goaresv3/router" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("no .env file found, relying on environment variables") + } + + if err := config.ConnectDB(); err != nil { + log.Fatalf("database: %v", err) + } + + if err := config.RunAutoMigrate(); err != nil { + log.Fatalf("migration: %v", err) + } + if err := config.SeedSecurityDefaults(); err != nil { + log.Fatalf("security seed: %v", err) + } + + r := gin.Default() + r.Use(middleware.DynamicCORS()) + r.Use(middleware.DynamicRateLimit()) + if err := r.SetTrustedProxies([]string{"127.0.0.1"}); err != nil { + log.Fatalf("failed to set trusted proxies: %v", err) + } + router.Setup(r) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + if err := r.Run(":" + port); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go new file mode 100644 index 0000000..428057f --- /dev/null +++ b/pkg/jwt/jwt.go @@ -0,0 +1,64 @@ +package jwt + +import ( + "errors" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + UserName string `json:"username,omitempty"` + jwt.RegisteredClaims +} + +// GenerateAccessToken creates a short-lived access token (15 minutes). +func GenerateAccessToken(userID uint, email, userName string) (string, error) { + secret := os.Getenv("JWT_SECRET") + claims := Claims{ + UserID: userID, + Email: email, + UserName: userName, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) +} + +// GenerateRefreshToken creates a long-lived refresh token (7 days). +func GenerateRefreshToken(userID uint, email, userName string) (string, error) { + secret := os.Getenv("JWT_REFRESH_SECRET") + claims := Claims{ + UserID: userID, + Email: email, + UserName: userName, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) +} + +// ValidateToken parses and validates a token string using the provided secret. +func ValidateToken(tokenStr, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/pkg/jwt/jwt_test.go b/pkg/jwt/jwt_test.go new file mode 100644 index 0000000..4aa664a --- /dev/null +++ b/pkg/jwt/jwt_test.go @@ -0,0 +1,42 @@ +package jwt + +import ( + "testing" +) + +func TestGenerateAndValidateAccessToken(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret-1234567890") + + token, err := GenerateAccessToken(42, "user@example.com", "tester") + if err != nil { + t.Fatalf("GenerateAccessToken returned error: %v", err) + } + + claims, err := ValidateToken(token, "test-secret-1234567890") + if err != nil { + t.Fatalf("ValidateToken returned error: %v", err) + } + + if claims.UserID != 42 { + t.Fatalf("expected user_id=42, got %d", claims.UserID) + } + if claims.Email != "user@example.com" { + t.Fatalf("expected email=user@example.com, got %s", claims.Email) + } + if claims.UserName != "tester" { + t.Fatalf("expected username=tester, got %s", claims.UserName) + } +} + +func TestValidateTokenWrongSecretFails(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret-1234567890") + + token, err := GenerateAccessToken(1, "user@example.com", "tester") + if err != nil { + t.Fatalf("GenerateAccessToken returned error: %v", err) + } + + if _, err := ValidateToken(token, "wrong-secret"); err == nil { + t.Fatal("expected ValidateToken to fail with wrong secret") + } +} diff --git a/pkg/mailer/mailer.go b/pkg/mailer/mailer.go new file mode 100644 index 0000000..834b88f --- /dev/null +++ b/pkg/mailer/mailer.go @@ -0,0 +1,123 @@ +package mailer + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "os" + "strconv" + "strings" +) + +func envBool(key string) bool { + v, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(key))) + if err != nil { + return false + } + return v +} + +func Send(to, subject, body string) error { + host := strings.TrimSpace(os.Getenv("EMAIL_HOST")) + port := strings.TrimSpace(os.Getenv("EMAIL_PORT")) + from := strings.TrimSpace(os.Getenv("EMAIL_FROM")) + username := strings.TrimSpace(os.Getenv("EMAIL_HOST_USER")) + password := strings.TrimSpace(os.Getenv("EMAIL_HOST_PASSWORD")) + + if host == "" || port == "" || from == "" { + return fmt.Errorf("email configuration is incomplete") + } + + addr := host + ":" + port + msg := strings.Join([]string{ + "From: " + from, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + }, "\r\n") + + var auth smtp.Auth + if username != "" { + auth = smtp.PlainAuth("", username, password, host) + } + + useSSL := envBool("EMAIL_USE_SSL") + useTLS := envBool("EMAIL_USE_TLS") + + if useSSL { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) + if err != nil { + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, host) + if err != nil { + return err + } + defer client.Quit() + + if auth != nil { + if err := client.Auth(auth); err != nil { + return err + } + } + + if err := client.Mail(from); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write([]byte(msg)); err != nil { + _ = w.Close() + return err + } + return w.Close() + } + + if !useTLS { + return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg)) + } + + client, err := smtp.Dial(addr) + if err != nil { + return err + } + defer client.Quit() + + if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil { + return err + } + + if auth != nil { + if err := client.Auth(auth); err != nil { + return err + } + } + + if err := client.Mail(from); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write([]byte(msg)); err != nil { + _ = w.Close() + return err + } + return w.Close() +} diff --git a/pkg/mailer/mailer_test.go b/pkg/mailer/mailer_test.go new file mode 100644 index 0000000..e1e798c --- /dev/null +++ b/pkg/mailer/mailer_test.go @@ -0,0 +1,130 @@ +package mailer + +import ( + "bufio" + "net" + "strconv" + "strings" + "testing" +) + +func TestSendFailsWhenConfigMissing(t *testing.T) { + t.Setenv("EMAIL_HOST", "") + t.Setenv("EMAIL_PORT", "") + t.Setenv("EMAIL_FROM", "") + + if err := Send("user@example.com", "subj", "body"); err == nil { + t.Fatal("expected error for incomplete email config") + } +} + +func TestSendPlainSMTP(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer ln.Close() + + done := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + done <- err + return + } + defer conn.Close() + + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + write := func(s string) error { + if _, err := w.WriteString(s); err != nil { + return err + } + return w.Flush() + } + + if err := write("220 localhost Simple Mail Transfer Service Ready\r\n"); err != nil { + done <- err + return + } + + for { + line, err := r.ReadString('\n') + if err != nil { + done <- err + return + } + cmd := strings.TrimSpace(line) + + switch { + case strings.HasPrefix(strings.ToUpper(cmd), "EHLO") || strings.HasPrefix(strings.ToUpper(cmd), "HELO"): + if err := write("250-localhost\r\n250 AUTH PLAIN\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "MAIL FROM"): + if err := write("250 OK\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "RCPT TO"): + if err := write("250 OK\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "DATA"): + if err := write("354 End data with .\r\n"); err != nil { + done <- err + return + } + for { + d, err := r.ReadString('\n') + if err != nil { + done <- err + return + } + if strings.TrimSpace(d) == "." { + break + } + } + if err := write("250 OK queued\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "QUIT"): + _ = write("221 Bye\r\n") + done <- nil + return + default: + if err := write("250 OK\r\n"); err != nil { + done <- err + return + } + } + } + }() + + host, portStr, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatalf("failed to parse listener address: %v", err) + } + if _, err := strconv.Atoi(portStr); err != nil { + t.Fatalf("invalid test smtp port: %v", err) + } + + t.Setenv("EMAIL_HOST", host) + t.Setenv("EMAIL_PORT", portStr) + t.Setenv("EMAIL_FROM", "noreply@example.com") + t.Setenv("EMAIL_HOST_USER", "") + t.Setenv("EMAIL_HOST_PASSWORD", "") + t.Setenv("EMAIL_USE_TLS", "false") + t.Setenv("EMAIL_USE_SSL", "false") + + if err := Send("user@example.com", "Verify", "Hello"); err != nil { + t.Fatalf("expected send success, got error: %v", err) + } + + if err := <-done; err != nil { + t.Fatalf("smtp server finished with error: %v", err) + } +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go new file mode 100644 index 0000000..786ea53 --- /dev/null +++ b/pkg/middleware/auth.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + + accountModels "goaresv3/app/accounts/models" + "goaresv3/config" + jwtHelper "goaresv3/pkg/jwt" +) + +// AuthRequired validates the Bearer access token and injects claims into context. +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header missing or malformed"}) + return + } + + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := jwtHelper.ValidateToken(tokenStr, os.Getenv("JWT_SECRET")) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired access token"}) + return + } + + c.Set("user_id", claims.UserID) + c.Set("email", claims.Email) + c.Set("username", claims.UserName) + c.Next() + } +} + +// AdminRequired checks whether the authenticated user has admin privileges. +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetUint("user_id") + if userID == 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if config.DB == nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "database is not connected"}) + return + } + + var user accountModels.User + if err := config.DB.Select("id", "is_admin").First(&user, userID).Error; err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid user"}) + return + } + if user.IsAdmin == nil || !*user.IsAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin role required"}) + return + } + + c.Next() + } +} diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go new file mode 100644 index 0000000..6b547ef --- /dev/null +++ b/pkg/middleware/auth_test.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + jwtHelper "goaresv3/pkg/jwt" +) + +func TestAuthRequiredValidBearerPasses(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + + token, err := jwtHelper.GenerateAccessToken(7, "u@example.com", "user7") + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + + r := gin.New() + r.GET("/protected", AuthRequired(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestAuthRequiredRawTokenRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + + token, err := jwtHelper.GenerateAccessToken(7, "u@example.com", "user7") + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + + r := gin.New() + r.GET("/protected", AuthRequired(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} diff --git a/pkg/middleware/cors_dynamic.go b/pkg/middleware/cors_dynamic.go new file mode 100644 index 0000000..dd89fab --- /dev/null +++ b/pkg/middleware/cors_dynamic.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "goaresv3/app/settings/models" + "goaresv3/config" +) + +// DynamicCORS applies CORS policy from DB-backed whitelist/blacklist tables. +func DynamicCORS() gin.HandlerFunc { + debug := envBool("CORS_DEBUG", false) + + return func(c *gin.Context) { + // Defaults for downstream middlewares (e.g. rate limit) + c.Set("origin_whitelisted", false) + c.Set("origin_blacklisted", false) + + origin := strings.TrimSpace(c.GetHeader("Origin")) + if origin == "" { + policyLogf(debug, "[cors] skip: no origin method=%s path=%s", c.Request.Method, c.Request.URL.Path) + c.Next() + return + } + + if config.DB == nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "database is not connected"}) + return + } + + var blocked models.CorsBlacklist + if err := config.DB. + Where("origin = ? AND is_active = ?", origin, true). + First(&blocked).Error; err == nil { + c.Set("origin_blacklisted", true) + policyLogf(debug, "[cors] blocked origin=%s method=%s path=%s", origin, c.Request.Method, c.Request.URL.Path) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "origin is blocked"}) + return + } + + var whitelisted models.CorsWhitelist + if err := config.DB. + Where("origin = ? AND is_active = ?", origin, true). + First(&whitelisted).Error; err == nil { + c.Set("origin_whitelisted", true) + policyLogf(debug, "[cors] whitelisted origin=%s method=%s path=%s", origin, c.Request.Method, c.Request.URL.Path) + } else { + policyLogf(debug, "[cors] pass(non-listed) origin=%s method=%s path=%s", origin, c.Request.Method, c.Request.URL.Path) + } + + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") + c.Header("Access-Control-Allow-Credentials", "true") + + if c.Request.Method == http.MethodOptions { + policyLogf(debug, "[cors] preflight origin=%s path=%s", origin, c.Request.URL.Path) + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/pkg/middleware/dynamic_policies_test.go b/pkg/middleware/dynamic_policies_test.go new file mode 100644 index 0000000..3e91c83 --- /dev/null +++ b/pkg/middleware/dynamic_policies_test.go @@ -0,0 +1,187 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + settingsModels "goaresv3/app/settings/models" + "goaresv3/config" +) + +func setupMiddlewareDB(t *testing.T) { + t.Helper() + dbName := strings.ReplaceAll(strings.ToLower(t.Name()), "/", "_") + dsn := "file:" + dbName + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &settingsModels.CorsWhitelist{}, + &settingsModels.CorsBlacklist{}, + &settingsModels.RateLimitSetting{}, + ); err != nil { + t.Fatalf("failed to migrate middleware models: %v", err) + } + config.DB = db +} + +func resetRateLimitState() { + rateLimitMu.Lock() + defer rateLimitMu.Unlock() + rateLimitBuckets = map[string]rateLimitBucket{} +} + +func TestDynamicCORSBlacklistBlocks(t *testing.T) { + gin.SetMode(gin.TestMode) + setupMiddlewareDB(t) + resetRateLimitState() + + if err := config.DB.Create(&settingsModels.CorsBlacklist{ + Origin: "https://blocked.example.com", + IsActive: true, + }).Error; err != nil { + t.Fatalf("failed to seed blacklist: %v", err) + } + + r := gin.New() + r.Use(DynamicCORS(), DynamicRateLimit()) + r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("Origin", "https://blocked.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for blacklisted origin, got %d", w.Code) + } +} + +func TestDynamicRateLimitSkipsWhitelistedOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + setupMiddlewareDB(t) + resetRateLimitState() + + if err := config.DB.Create(&settingsModels.CorsWhitelist{ + Origin: "https://trusted.example.com", + IsActive: true, + }).Error; err != nil { + t.Fatalf("failed to seed whitelist: %v", err) + } + if err := config.DB.Create(&settingsModels.RateLimitSetting{ + Name: "api", + MaxRequests: 1, + WindowSeconds: 60, + IsActive: true, + }).Error; err != nil { + t.Fatalf("failed to seed api rate limit: %v", err) + } + + r := gin.New() + r.Use(DynamicCORS(), DynamicRateLimit()) + r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("Origin", "https://trusted.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for whitelisted origin request #%d, got %d", i+1, w.Code) + } + } +} + +func TestDynamicRateLimitAppliesToNonListedOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + setupMiddlewareDB(t) + resetRateLimitState() + + if err := config.DB.Create(&settingsModels.RateLimitSetting{ + Name: "api", + MaxRequests: 2, + WindowSeconds: 60, + IsActive: true, + }).Error; err != nil { + t.Fatalf("failed to seed api rate limit: %v", err) + } + + r := gin.New() + r.Use(DynamicCORS(), DynamicRateLimit()) + r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("Origin", "https://unknown.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on allowed request #%d, got %d", i+1, w.Code) + } + } + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("Origin", "https://unknown.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 for over-limit request, got %d", w.Code) + } +} + +func TestDynamicRateLimitLoginAndRegisterSeparateRules(t *testing.T) { + gin.SetMode(gin.TestMode) + setupMiddlewareDB(t) + resetRateLimitState() + + seed := []settingsModels.RateLimitSetting{ + {Name: "api/v1/auth/login", MaxRequests: 1, WindowSeconds: 60, IsActive: true}, + {Name: "api/v1/auth/register", MaxRequests: 2, WindowSeconds: 60, IsActive: true}, + {Name: "api", MaxRequests: 10, WindowSeconds: 60, IsActive: true}, + } + for _, item := range seed { + it := item + if err := config.DB.Create(&it).Error; err != nil { + t.Fatalf("failed to seed rate limit %s: %v", it.Name, err) + } + } + + r := gin.New() + r.Use(DynamicCORS(), DynamicRateLimit()) + r.POST("/api/v1/auth/login", func(c *gin.Context) { c.Status(http.StatusOK) }) + r.POST("/api/v1/auth/register", func(c *gin.Context) { c.Status(http.StatusOK) }) + + login1 := httptest.NewRecorder() + r.ServeHTTP(login1, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)) + if login1.Code != http.StatusOK { + t.Fatalf("expected login first request 200, got %d", login1.Code) + } + login2 := httptest.NewRecorder() + r.ServeHTTP(login2, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)) + if login2.Code != http.StatusTooManyRequests { + t.Fatalf("expected login second request 429, got %d", login2.Code) + } + + reg1 := httptest.NewRecorder() + r.ServeHTTP(reg1, httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", nil)) + if reg1.Code != http.StatusOK { + t.Fatalf("expected register first request 200, got %d", reg1.Code) + } + reg2 := httptest.NewRecorder() + r.ServeHTTP(reg2, httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", nil)) + if reg2.Code != http.StatusOK { + t.Fatalf("expected register second request 200, got %d", reg2.Code) + } + reg3 := httptest.NewRecorder() + r.ServeHTTP(reg3, httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", nil)) + if reg3.Code != http.StatusTooManyRequests { + t.Fatalf("expected register third request 429, got %d", reg3.Code) + } +} diff --git a/pkg/middleware/log_flags.go b/pkg/middleware/log_flags.go new file mode 100644 index 0000000..7b273ef --- /dev/null +++ b/pkg/middleware/log_flags.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "log" + "os" + "strings" +) + +func envBool(key string, fallback bool) bool { + raw := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if raw == "" { + return fallback + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func policyLogf(enabled bool, format string, args ...any) { + if !enabled { + return + } + log.Printf(format, args...) +} diff --git a/pkg/middleware/rate_limit_dynamic.go b/pkg/middleware/rate_limit_dynamic.go new file mode 100644 index 0000000..b1f3d31 --- /dev/null +++ b/pkg/middleware/rate_limit_dynamic.go @@ -0,0 +1,145 @@ +package middleware + +import ( + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "goaresv3/app/settings/models" + "goaresv3/config" +) + +type rateLimitBucket struct { + Count int64 + WindowEnds time.Time +} + +var ( + rateLimitMu sync.Mutex + rateLimitBuckets = map[string]rateLimitBucket{} +) + +// DynamicRateLimit enforces DB-backed rate-limit settings. +// Rule selection order: +// 1) Exact route path key (without leading slash), e.g. api/v1/auth/login +// 2) "api" fallback +func DynamicRateLimit() gin.HandlerFunc { + debug := envBool("RATE_LIMIT_DEBUG", false) + + return func(c *gin.Context) { + pathKey := strings.TrimPrefix(c.FullPath(), "/") + if pathKey == "" { + pathKey = strings.TrimPrefix(c.Request.URL.Path, "/") + } + if shouldSkipRateLimit(pathKey) { + policyLogf(debug, "[rate-limit] skip path=%s reason=skip-list", pathKey) + c.Next() + return + } + + if isWhitelisted, ok := c.Get("origin_whitelisted"); ok { + if v, castOK := isWhitelisted.(bool); castOK && v { + // Whitelisted origins are excluded from rate limiting. + policyLogf(debug, "[rate-limit] skip path=%s reason=origin-whitelisted", pathKey) + c.Next() + return + } + } + + if config.DB == nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "database is not connected"}) + return + } + + rule, ok := resolveRateLimitRule(pathKey) + if !ok { + policyLogf(debug, "[rate-limit] no-rule path=%s", pathKey) + c.Next() + return + } + + clientIP := c.ClientIP() + windowDur := time.Duration(rule.WindowSeconds) * time.Second + bucketKey := rule.Name + ":" + clientIP + + now := time.Now() + rateLimitMu.Lock() + bucket, ok := rateLimitBuckets[bucketKey] + if !ok || now.After(bucket.WindowEnds) { + bucket = rateLimitBucket{ + Count: 0, + WindowEnds: now.Add(windowDur), + } + } + + bucket.Count++ + rateLimitBuckets[bucketKey] = bucket + remaining := rule.MaxRequests - bucket.Count + resetIn := int(time.Until(bucket.WindowEnds).Seconds()) + rateLimitMu.Unlock() + + if remaining < 0 { + policyLogf(debug, "[rate-limit] blocked path=%s rule=%s ip=%s limit=%d window=%d", pathKey, rule.Name, clientIP, rule.MaxRequests, rule.WindowSeconds) + c.Header("Retry-After", strconvItoa(maxInt(resetIn, 1))) + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + "limit": rule.MaxRequests, + "window_sec": rule.WindowSeconds, + "retry_after": maxInt(resetIn, 1), + "rule_name": rule.Name, + "client_ip": clientIP, + "request_path": pathKey, + }) + return + } + + c.Header("X-RateLimit-Limit", strconvI64(rule.MaxRequests)) + c.Header("X-RateLimit-Remaining", strconvI64(maxI64(remaining, 0))) + c.Header("X-RateLimit-Reset", strconvItoa(maxInt(resetIn, 0))) + policyLogf(debug, "[rate-limit] pass path=%s rule=%s ip=%s remaining=%d", pathKey, rule.Name, clientIP, maxI64(remaining, 0)) + c.Next() + } +} + +func shouldSkipRateLimit(pathKey string) bool { + return strings.HasPrefix(pathKey, "swagger") +} + +func resolveRateLimitRule(pathKey string) (models.RateLimitSetting, bool) { + var rule models.RateLimitSetting + res := config.DB.Where("name = ? AND is_active = ?", pathKey, true).Limit(1).Find(&rule) + if res.Error == nil && res.RowsAffected > 0 { + return rule, true + } + res = config.DB.Where("name = ? AND is_active = ?", "api", true).Limit(1).Find(&rule) + if res.Error == nil && res.RowsAffected > 0 { + return rule, true + } + return models.RateLimitSetting{}, false +} + +func strconvItoa(v int) string { + return strconv.FormatInt(int64(v), 10) +} + +func strconvI64(v int64) string { + return strconv.FormatInt(v, 10) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func maxI64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/pkg/swaggerui/initializer.go b/pkg/swaggerui/initializer.go new file mode 100644 index 0000000..ccc8e4e --- /dev/null +++ b/pkg/swaggerui/initializer.go @@ -0,0 +1,60 @@ +package swaggerui + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +const initializerJS = `window.onload = function() { + const ui = SwaggerUIBundle({ + url: "doc.json", + dom_id: '#swagger-ui', + validatorUrl: null, + persistAuthorization: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout", + docExpansion: "list", + deepLinking: true, + defaultModelsExpandDepth: 1, + requestInterceptor: function(request) { + const auth = request.headers.Authorization || request.headers.authorization + + if (typeof auth === 'string') { + const trimmed = auth.trim() + + if (trimmed !== '' && !/^Bearer\s+/i.test(trimmed)) { + request.headers.Authorization = 'Bearer ' + trimmed + } + } + + return request + } + }) + + window.ui = ui +} +` + +// Handler serves Swagger UI and overrides the initializer script to prefix raw tokens with Bearer. +func Handler() gin.HandlerFunc { + defaultHandler := ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.PersistAuthorization(true)) + + return func(c *gin.Context) { + if strings.HasSuffix(c.Request.URL.Path, "/swagger-initializer.js") { + c.Data(http.StatusOK, "application/javascript", []byte(initializerJS)) + return + } + + defaultHandler(c) + } +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..140ce07 --- /dev/null +++ b/router/router.go @@ -0,0 +1,120 @@ +package router + +import ( + "github.com/gin-gonic/gin" + + userControllers "goaresv3/app/accounts/controllers" + blogControllers "goaresv3/app/blog/controllers" + settingsControllers "goaresv3/app/settings/controllers" + shopControllers "goaresv3/app/shop/controllers" + "goaresv3/pkg/middleware" + "goaresv3/pkg/swaggerui" +) + +// Setup registers all application routes. +func Setup(r *gin.Engine) { + r.GET("/swagger/*any", swaggerui.Handler()) + + // ── Public auth routes ─────────────────────────────────────────────────── + auth := r.Group("/api/v1/auth") + { + auth.POST("/register", userControllers.Register) + auth.GET("/verify-email", userControllers.VerifyEmail) + auth.POST("/login", userControllers.Login) + auth.POST("/refresh", userControllers.RefreshToken) + auth.GET("/google/login", userControllers.GoogleLogin) + auth.GET("/google/callback", userControllers.GoogleCallback) + auth.GET("/github/login", userControllers.GitHubLogin) + auth.GET("/github/callback", userControllers.GitHubCallback) + } + + // ── Public content routes ────────────────────────────────────────────────── + public := r.Group("/api/v1") + { + // Settings (public read) + public.GET("/settings", settingsControllers.GetSetting) + public.GET("/settings/heroes", settingsControllers.ListHeroes) + + // Shop (public read) + public.GET("/shop/categories", shopControllers.ListProductCategories) + public.GET("/shop/tags", shopControllers.ListProductTags) + public.GET("/shop/products", shopControllers.ListProducts) + public.GET("/shop/products/:id", shopControllers.GetProduct) + + // Blog (public read) + public.GET("/blog/categories", blogControllers.ListCategories) + public.GET("/blog/tags", blogControllers.ListTags) + public.GET("/blog/posts", blogControllers.ListPosts) + public.GET("/blog/posts/:id", blogControllers.GetPost) + } + + // ── Protected routes (require valid access token) ──────────────────────── + api := r.Group("/api/v1", middleware.AuthRequired()) + { + api.GET("/me", userControllers.Me) + + // Shop (user cart operations) + api.GET("/shop/cart", shopControllers.GetMyCart) + api.POST("/shop/cart/items", shopControllers.AddCartItem) + api.PUT("/shop/cart/items/:itemId", shopControllers.UpdateCartItem) + api.DELETE("/shop/cart/items/:itemId", shopControllers.DeleteCartItem) + } + + admin := api.Group("", middleware.AdminRequired()) + { + // Settings + admin.PUT("/settings", settingsControllers.UpsertSetting) + // Hero + admin.POST("/settings/heroes", settingsControllers.CreateHero) + admin.PUT("/settings/heroes/:id", settingsControllers.UpdateHero) + admin.DELETE("/settings/heroes/:id", settingsControllers.DeleteHero) + + // CORS whitelist + admin.GET("/settings/cors/whitelist", settingsControllers.ListCorsWhitelists) + admin.POST("/settings/cors/whitelist", settingsControllers.CreateCorsWhitelist) + admin.PUT("/settings/cors/whitelist/:id", settingsControllers.UpdateCorsWhitelist) + admin.DELETE("/settings/cors/whitelist/:id", settingsControllers.DeleteCorsWhitelist) + + // CORS blacklist + admin.GET("/settings/cors/blacklist", settingsControllers.ListCorsBlacklists) + admin.POST("/settings/cors/blacklist", settingsControllers.CreateCorsBlacklist) + admin.PUT("/settings/cors/blacklist/:id", settingsControllers.UpdateCorsBlacklist) + admin.DELETE("/settings/cors/blacklist/:id", settingsControllers.DeleteCorsBlacklist) + + // Rate limits + admin.GET("/settings/rate-limits", settingsControllers.ListRateLimits) + admin.POST("/settings/rate-limits", settingsControllers.CreateRateLimit) + admin.PUT("/settings/rate-limits/:id", settingsControllers.UpdateRateLimit) + admin.DELETE("/settings/rate-limits/:id", settingsControllers.DeleteRateLimit) + + // Shop categories + admin.POST("/shop/categories", shopControllers.CreateProductCategory) + admin.PUT("/shop/categories/:id", shopControllers.UpdateProductCategory) + admin.DELETE("/shop/categories/:id", shopControllers.DeleteProductCategory) + + // Shop tags + admin.POST("/shop/tags", shopControllers.CreateProductTag) + admin.PUT("/shop/tags/:id", shopControllers.UpdateProductTag) + admin.DELETE("/shop/tags/:id", shopControllers.DeleteProductTag) + + // Shop products + admin.POST("/shop/products", shopControllers.CreateProduct) + admin.PUT("/shop/products/:id", shopControllers.UpdateProduct) + admin.DELETE("/shop/products/:id", shopControllers.DeleteProduct) + + // Blog categories + admin.POST("/blog/categories", blogControllers.CreateCategory) + admin.PUT("/blog/categories/:id", blogControllers.UpdateCategory) + admin.DELETE("/blog/categories/:id", blogControllers.DeleteCategory) + + // Blog tags + admin.POST("/blog/tags", blogControllers.CreateTag) + admin.PUT("/blog/tags/:id", blogControllers.UpdateTag) + admin.DELETE("/blog/tags/:id", blogControllers.DeleteTag) + + // Blog posts + admin.POST("/blog/posts", blogControllers.CreatePost) + admin.PUT("/blog/posts/:id", blogControllers.UpdatePost) + admin.DELETE("/blog/posts/:id", blogControllers.DeletePost) + } +} diff --git a/router/router_test.go b/router/router_test.go new file mode 100644 index 0000000..7a532fc --- /dev/null +++ b/router/router_test.go @@ -0,0 +1,321 @@ +package router + +import ( + "bufio" + "bytes" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "goaresv3/app/accounts/models" + "goaresv3/config" + jwtHelper "goaresv3/pkg/jwt" +) + +func setupRouterTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:router_test?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.User{}, &models.SocialAccount{}, &models.Profile{}); err != nil { + t.Fatalf("failed to migrate models: %v", err) + } + config.DB = db + return db +} + +func startFakeSMTP(t *testing.T) (host, port string, done chan error) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start fake smtp: %v", err) + } + + done = make(chan error, 1) + go func() { + defer ln.Close() + + conn, err := ln.Accept() + if err != nil { + done <- err + return + } + defer conn.Close() + + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + write := func(s string) error { + if _, err := w.WriteString(s); err != nil { + return err + } + return w.Flush() + } + + if err := write("220 localhost SMTP ready\r\n"); err != nil { + done <- err + return + } + + for { + line, err := r.ReadString('\n') + if err != nil { + done <- err + return + } + cmd := strings.TrimSpace(line) + + switch { + case strings.HasPrefix(strings.ToUpper(cmd), "EHLO") || strings.HasPrefix(strings.ToUpper(cmd), "HELO"): + if err := write("250-localhost\r\n250 AUTH PLAIN\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "MAIL FROM"): + if err := write("250 OK\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "RCPT TO"): + if err := write("250 OK\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "DATA"): + if err := write("354 End data with .\r\n"); err != nil { + done <- err + return + } + for { + d, err := r.ReadString('\n') + if err != nil { + done <- err + return + } + if strings.TrimSpace(d) == "." { + break + } + } + if err := write("250 OK queued\r\n"); err != nil { + done <- err + return + } + case strings.HasPrefix(strings.ToUpper(cmd), "QUIT"): + _ = write("221 Bye\r\n") + done <- nil + return + default: + if err := write("250 OK\r\n"); err != nil { + done <- err + return + } + } + } + }() + + h, p, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatalf("failed to parse fake smtp address: %v", err) + } + if _, err := strconv.Atoi(p); err != nil { + t.Fatalf("invalid fake smtp port: %v", err) + } + return h, p, done +} + +func TestAllEndpointsFlow(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupRouterTestDB(t) + + host, port, smtpDone := startFakeSMTP(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + t.Setenv("APP_BASE_URL", "http://localhost:8080") + t.Setenv("EMAIL_HOST", host) + t.Setenv("EMAIL_PORT", port) + t.Setenv("EMAIL_HOST_USER", "") + t.Setenv("EMAIL_HOST_PASSWORD", "") + t.Setenv("EMAIL_USE_TLS", "false") + t.Setenv("EMAIL_USE_SSL", "false") + t.Setenv("EMAIL_FROM", "noreply@example.com") + + r := gin.New() + Setup(r) + + regBody := []byte(`{"username":"ali","email":"ali@example.com","password":"password123","confirm_password":"password123"}`) + regReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody)) + regReq.Header.Set("Content-Type", "application/json") + regResp := httptest.NewRecorder() + r.ServeHTTP(regResp, regReq) + if regResp.Code != http.StatusCreated { + t.Fatalf("register expected 201, got %d body=%s", regResp.Code, regResp.Body.String()) + } + + if err := <-smtpDone; err != nil { + t.Fatalf("fake smtp ended with error: %v", err) + } + + invalidVerifyReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=bad-token", nil) + invalidVerifyResp := httptest.NewRecorder() + r.ServeHTTP(invalidVerifyResp, invalidVerifyReq) + if invalidVerifyResp.Code != http.StatusBadRequest { + t.Fatalf("invalid verify expected 400, got %d", invalidVerifyResp.Code) + } + + loginBody := []byte(`{"email":"ali@example.com","password":"password123"}`) + loginBeforeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + loginBeforeReq.Header.Set("Content-Type", "application/json") + loginBeforeResp := httptest.NewRecorder() + r.ServeHTTP(loginBeforeResp, loginBeforeReq) + if loginBeforeResp.Code != http.StatusForbidden { + t.Fatalf("login before verify expected 403, got %d", loginBeforeResp.Code) + } + + var user models.User + if err := db.Where("email = ?", "ali@example.com").First(&user).Error; err != nil { + t.Fatalf("failed to fetch registered user: %v", err) + } + if user.EmailVerifyToken == "" { + t.Fatal("expected email verify token to be set") + } + + verifyReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token="+user.EmailVerifyToken, nil) + verifyResp := httptest.NewRecorder() + r.ServeHTTP(verifyResp, verifyReq) + if verifyResp.Code != http.StatusOK { + t.Fatalf("verify expected 200, got %d", verifyResp.Code) + } + + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginResp := httptest.NewRecorder() + r.ServeHTTP(loginResp, loginReq) + if loginResp.Code != http.StatusOK { + t.Fatalf("login expected 200, got %d body=%s", loginResp.Code, loginResp.Body.String()) + } + + var loginJSON map[string]any + if err := json.Unmarshal(loginResp.Body.Bytes(), &loginJSON); err != nil { + t.Fatalf("failed to decode login response: %v", err) + } + accessToken, _ := loginJSON["access_token"].(string) + refreshToken, _ := loginJSON["refresh_token"].(string) + if accessToken == "" || refreshToken == "" { + t.Fatal("expected access_token and refresh_token in login response") + } + + refreshBody := []byte(`{"refresh_token":"` + refreshToken + `"}`) + refreshReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(refreshBody)) + refreshReq.Header.Set("Content-Type", "application/json") + refreshResp := httptest.NewRecorder() + r.ServeHTTP(refreshResp, refreshReq) + if refreshResp.Code != http.StatusOK { + t.Fatalf("refresh expected 200, got %d body=%s", refreshResp.Code, refreshResp.Body.String()) + } + + meReq := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + meReq.Header.Set("Authorization", "Bearer "+accessToken) + meResp := httptest.NewRecorder() + r.ServeHTTP(meResp, meReq) + if meResp.Code != http.StatusOK { + t.Fatalf("me expected 200, got %d body=%s", meResp.Code, meResp.Body.String()) + } + + var meJSON map[string]any + if err := json.Unmarshal(meResp.Body.Bytes(), &meJSON); err != nil { + t.Fatalf("failed to decode me response: %v", err) + } + if _, ok := meJSON["username"]; !ok { + t.Fatal("expected username in /api/v1/me response") + } + + swaggerReq := httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil) + swaggerResp := httptest.NewRecorder() + r.ServeHTTP(swaggerResp, swaggerReq) + if swaggerResp.Code != http.StatusOK { + t.Fatalf("swagger index expected 200, got %d", swaggerResp.Code) + } + + initializerReq := httptest.NewRequest(http.MethodGet, "/swagger/swagger-initializer.js", nil) + initializerResp := httptest.NewRecorder() + r.ServeHTTP(initializerResp, initializerReq) + if initializerResp.Code != http.StatusOK { + t.Fatalf("swagger initializer expected 200, got %d", initializerResp.Code) + } + if !strings.Contains(initializerResp.Body.String(), "requestInterceptor") { + t.Fatal("expected requestInterceptor in swagger initializer") + } +} + +func TestProtectedEndpointWithoutAuthHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + setupRouterTestDB(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + + r := gin.New() + Setup(r) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestProtectedEndpointWithRawTokenRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + setupRouterTestDB(t) + t.Setenv("JWT_SECRET", "test-secret-1234567890") + t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") + + verified := true + user := models.User{UserName: "raw", Email: "raw@example.com", EmailVerified: &verified} + if err := config.DB.Create(&user).Error; err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + access, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName) + if err != nil { + t.Fatalf("failed to generate access token: %v", err) + } + + r := gin.New() + Setup(r) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + req.Header.Set("Authorization", access) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestSwaggerDisallowsNonGET(t *testing.T) { + gin.SetMode(gin.TestMode) + setupRouterTestDB(t) + + r := gin.New() + Setup(r) + + req := httptest.NewRequest(http.MethodPost, "/swagger/index.html", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} diff --git a/sudo nmcli con add type ethernet ifname b/sudo nmcli con add type ethernet ifname new file mode 100644 index 0000000..9bfb25e --- /dev/null +++ b/sudo nmcli con add type ethernet ifname @@ -0,0 +1 @@ +sudo nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-slave \ No newline at end of file diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..52f8e56 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main new file mode 100755 index 0000000..b5b63a8 Binary files /dev/null and b/tmp/main differ diff --git a/yapi.md b/yapi.md new file mode 100644 index 0000000..04e989f --- /dev/null +++ b/yapi.md @@ -0,0 +1,23 @@ +/benim-projem +├── main.go # Uygulamanın giriş noktası +├── go.mod +├── config/ # Veritabanı ve çevre değişkenleri yapılandırması +├── pkg/ # Paylaşılan yardımcı araçlar (middleware, logger) +├── app/ # Django'daki "apps" klasörü gibi +│ ├── account/ +│ │ ├── controllers/ # (veya handlers) HTTP isteklerini karşılar +│ │ ├── models/ # GORM struct'ları +│ │ ├── services/ # İş mantığının (business logic) döndüğü yer +│ │ └── repository/ # Veritabanı sorgularının yapıldığı yer (isteğe bağlı) +│ └── blog/ +│ ├── controllers/ +│ ├── models/ +│ ├── services/ +│ └── repository/ +└── router/ # Tüm modüllerin rotalarının birleştiği yer + +POST /api/v1/auth/register { username, email, password } +POST /api/v1/auth/login { email, password } → access_token + refresh_token +POST /api/v1/auth/refresh { refresh_token } → yeni access_token + +GET /api/v1/me Authorization: Bearer \ No newline at end of file