first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:41:46 +03:00
commit b6e74bd024
56 changed files with 16114 additions and 0 deletions

58
.air.toml Normal file
View File

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

47
.env Normal file
View File

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

10
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

9
.idea/goaresv3.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/goaresv3.iml" filepath="$PROJECT_DIR$/.idea/goaresv3.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

124
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="ALL" />
</component>
<component name="ChangeListManager">
<list default="true" id="f1502c38-2440-4f14-8970-1d12d92ec479" name="Changes" comment="Zip Sildim">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/router/router.go" beforeDir="false" afterPath="$PROJECT_DIR$/router/router.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tmp/main" beforeDir="false" afterPath="$PROJECT_DIR$/tmp/main" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="CopilotPersistence">
<persistenceIdMap>
<entry key="_/home/beyhan/Projeler/Go/goaresv3" value="3BoDJo2MRhzRALFTpWv4Ji4V7oY" />
</persistenceIdMap>
</component>
<component name="GOROOT" url="file:///usr/lib/go" />
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 7,
&quot;fromUser&quot;: false
}</component>
<component name="ProjectId" id="3BoDJo2MRhzRALFTpWv4Ji4V7oY" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;Go Test.go test goaresv3/pkg/jwt.executor&quot;: &quot;Run&quot;,
&quot;Go Test.go test goaresv3/router.executor&quot;: &quot;Run&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.GoLinterPluginOnboardingV2&quot;: &quot;true&quot;,
&quot;RunOnceActivity.GoLinterPluginStorageMigration&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.analysis.ui.options.defaults&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.formatter.settings.were.checked&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.modules.go.list.on.any.changes.was.set&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;go.sdk.automatically.set&quot;: &quot;true&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/beyhan/Projeler/Go/goaresv3&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager">
<configuration name="go test goaresv3/pkg/jwt" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true">
<module name="goaresv3" />
<working_directory value="$PROJECT_DIR$/pkg/jwt" />
<root_directory value="$PROJECT_DIR$" />
<kind value="PACKAGE" />
<package value="goaresv3/pkg/jwt" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$" />
<framework value="gotest" />
<method v="2" />
</configuration>
<configuration name="go test goaresv3/router" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true">
<module name="goaresv3" />
<working_directory value="$PROJECT_DIR$/router" />
<root_directory value="$PROJECT_DIR$" />
<kind value="PACKAGE" />
<package value="goaresv3/router" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$" />
<framework value="gotest" />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-b63d5a1f7c97-b61e75351b1f-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.22158.340" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="f1502c38-2440-4f14-8970-1d12d92ec479" name="Changes" comment="" />
<created>1775146460804</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1775146460804</updated>
<workItem from="1775146462091" duration="54000" />
<workItem from="1776335118074" duration="15000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="VcsManagerConfiguration">
<option name="CHECK_NEW_TODO" value="false" />
<MESSAGE value="Zip Sildim" />
<option name="LAST_COMMIT_MESSAGE" value="Zip Sildim" />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>

View File

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

View File

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

View File

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

View File

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

View File

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

53
app/blog/models/blog.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
app/shop/models/cart.go Normal file
View File

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

View File

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

12
belgeler/README.md Normal file
View File

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

267
belgeler/api-referans.md Normal file
View File

@@ -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, 350 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 <access_token>` 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=<verification_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 <access_token>"
# Token yenile
curl -X POST http://localhost:8080/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"<refresh_token>"}'
```
---
## Swagger
- UI: `http://localhost:8080/swagger/index.html`
- OpenAPI dosyaları: `docs/swagger.json`, `docs/swagger.yaml`

201
belgeler/auth.md Normal file
View File

@@ -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 <access> ───► │ 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
```

View File

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

56
belgeler/proje-yapisi.md Normal file
View File

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

88
belgeler/testler.md Normal file
View File

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

159
belgeler/veritabani.md Normal file
View File

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

50
build_windows.sh Executable file
View File

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

BIN
builds/goaresv3_x64.exe Executable file

Binary file not shown.

230
config/database.go Normal file
View File

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

3498
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

3477
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

2285
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
env.zip Normal file

Binary file not shown.

67
go.mod Normal file
View File

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

199
go.sum Normal file
View File

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

BIN
goaresv3 Executable file

Binary file not shown.

57
main.go Normal file
View File

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

64
pkg/jwt/jwt.go Normal file
View File

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

42
pkg/jwt/jwt_test.go Normal file
View File

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

123
pkg/mailer/mailer.go Normal file
View File

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

130
pkg/mailer/mailer_test.go Normal file
View File

@@ -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 <CR><LF>.<CR><LF>\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)
}
}

63
pkg/middleware/auth.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

120
router/router.go Normal file
View File

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

321
router/router_test.go Normal file
View File

@@ -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 <CR><LF>.<CR><LF>\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)
}
}

View File

@@ -0,0 +1 @@
sudo nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-slave

1
tmp/build-errors.log Normal file
View File

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

BIN
tmp/main Executable file

Binary file not shown.

23
yapi.md Normal file
View File

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