first commit
This commit is contained in:
58
.air.toml
Normal file
58
.air.toml
Normal 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
47
.env
Normal 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
10
.idea/go.imports.xml
generated
Normal 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
9
.idea/goaresv3.iml
generated
Normal 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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
124
.idea/workspace.xml
generated
Normal 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">{
|
||||
"associatedIndex": 7,
|
||||
"fromUser": false
|
||||
}</component>
|
||||
<component name="ProjectId" id="3BoDJo2MRhzRALFTpWv4Ji4V7oY" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||
"Go Test.go test goaresv3/pkg/jwt.executor": "Run",
|
||||
"Go Test.go test goaresv3/router.executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.GoLinterPluginOnboardingV2": "true",
|
||||
"RunOnceActivity.GoLinterPluginStorageMigration": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.go.analysis.ui.options.defaults": "true",
|
||||
"RunOnceActivity.go.formatter.settings.were.checked": "true",
|
||||
"RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"go.sdk.automatically.set": "true",
|
||||
"junie.onboarding.icon.badge.shown": "true",
|
||||
"last_opened_file_path": "/home/beyhan/Projeler/Go/goaresv3",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"to.speed.mode.migration.done": "true",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</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>
|
||||
823
app/accounts/controllers/user.go
Normal file
823
app/accounts/controllers/user.go
Normal 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,
|
||||
})
|
||||
}
|
||||
652
app/accounts/controllers/user_test.go
Normal file
652
app/accounts/controllers/user_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
48
app/accounts/models/accounts.go
Normal file
48
app/accounts/models/accounts.go
Normal 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
|
||||
|
||||
}
|
||||
27
app/accounts/models/token.go
Normal file
27
app/accounts/models/token.go
Normal 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"`
|
||||
}
|
||||
|
||||
425
app/blog/controllers/blog.go
Normal file
425
app/blog/controllers/blog.go
Normal 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
53
app/blog/models/blog.go
Normal 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"`
|
||||
}
|
||||
747
app/settings/controllers/settings.go
Normal file
747
app/settings/controllers/settings.go
Normal 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"})
|
||||
}
|
||||
34
app/settings/models/cors.go
Normal file
34
app/settings/models/cors.go
Normal 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"`
|
||||
}
|
||||
23
app/settings/models/hero.go
Normal file
23
app/settings/models/hero.go
Normal 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"`
|
||||
}
|
||||
43
app/settings/models/setting.go
Normal file
43
app/settings/models/setting.go
Normal 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"
|
||||
}
|
||||
665
app/shop/controllers/shop.go
Normal file
665
app/shop/controllers/shop.go
Normal 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
20
app/shop/models/cart.go
Normal 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"`
|
||||
}
|
||||
53
app/shop/models/product.go
Normal file
53
app/shop/models/product.go
Normal 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
12
belgeler/README.md
Normal 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
267
belgeler/api-referans.md
Normal 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, 3–50 karakter |
|
||||
| `email` | string | Zorunlu, geçerli e-posta |
|
||||
| `password` | string | Zorunlu, min 8 karakter |
|
||||
| `confirm_password` | string | Zorunlu, `password` ile aynı olmalı |
|
||||
|
||||
**Yanıtlar**
|
||||
|
||||
| Durum | Açıklama | Gövde örneği |
|
||||
|---|---|---|
|
||||
| `201 Created` | Kayıt başarılı, doğrulama maili gönderildi | `{ "message": "user created. please verify your email", "user_id": 1 }` |
|
||||
| `400 Bad Request` | Doğrulama hatası | `{ "error": "Key: 'RegisterRequest.Email' Error:..." }` |
|
||||
| `400 Bad Request` | Şifreler eşleşmiyor | `{ "error": "password and confirm_password do not match" }` |
|
||||
| `409 Conflict` | E-posta zaten kayıtlı | `{ "error": "email already in use" }` |
|
||||
| `500 Internal Server Error` | Doğrulama maili gönderilemedi | `{ "error": "failed to send verification email" }` |
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/v1/auth/verify-email`
|
||||
|
||||
Kullanıcı email doğrulama linkindeki token ile hesabı aktive eder.
|
||||
|
||||
**Query parametreleri**
|
||||
|
||||
| Alan | Tip | Kural |
|
||||
|---|---|---|
|
||||
| `token` | string | Zorunlu |
|
||||
|
||||
**Yanıtlar**
|
||||
|
||||
| Durum | Açıklama | Gövde örneği |
|
||||
|---|---|---|
|
||||
| `200 OK` | Email doğrulandı | `{ "message": "email verified successfully" }` |
|
||||
| `400 Bad Request` | Token eksik/geçersiz | `{ "error": "invalid or expired verification token" }` |
|
||||
| `500 Internal Server Error` | Sunucu hatası | `{ "error": "could not verify email" }` |
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
E-posta ve şifre ile giriş yapar; token çifti döner.
|
||||
|
||||
**İstek gövdesi**
|
||||
```json
|
||||
{
|
||||
"email": "ali@ornek.com",
|
||||
"password": "gizli1234"
|
||||
}
|
||||
```
|
||||
|
||||
**Yanıtlar**
|
||||
|
||||
| Durum | Açıklama | Gövde örneği |
|
||||
|---|---|---|
|
||||
| `200 OK` | Giriş başarılı | Aşağıya bakın |
|
||||
| `400 Bad Request` | Eksik alan | `{ "error": "..." }` |
|
||||
| `401 Unauthorized` | Yanlış bilgi | `{ "error": "invalid email or password" }` |
|
||||
| `403 Forbidden` | Email doğrulanmamış | `{ "error": "email is not verified" }` |
|
||||
|
||||
```json
|
||||
// 200 OK
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/refresh`
|
||||
|
||||
Süresi dolmuş access token'ı yeniler.
|
||||
|
||||
**İstek gövdesi**
|
||||
```json
|
||||
{ "refresh_token": "eyJ..." }
|
||||
```
|
||||
|
||||
**Yanıtlar**
|
||||
|
||||
| Durum | Açıklama | Gövde örneği |
|
||||
|---|---|---|
|
||||
| `200 OK` | Yenileme başarılı | `{ "access_token": "eyJ...", "token_type": "Bearer" }` |
|
||||
| `400 Bad Request` | Eksik alan | `{ "error": "..." }` |
|
||||
| `401 Unauthorized` | Token geçersiz/süresi dolmuş | `{ "error": "invalid or expired refresh token" }` |
|
||||
| `403 Forbidden` | Email doğrulanmamış | `{ "error": "email is not verified" }` |
|
||||
|
||||
---
|
||||
|
||||
## Korumalı Endpoint'ler
|
||||
_`Authorization: Bearer <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
201
belgeler/auth.md
Normal 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
|
||||
```
|
||||
151
belgeler/ortam-degiskenleri.md
Normal file
151
belgeler/ortam-degiskenleri.md
Normal 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
56
belgeler/proje-yapisi.md
Normal 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ı açılır.
|
||||
3. `config.RunAutoMigrate()` tüm modüllerin şemalarını uygular.
|
||||
4. `config.SeedSecurityDefaults()` CORS/RateLimit başlangıç kayıtlarını (yoksa) ekler.
|
||||
5. Gin başlatılır, global middlewareler çalışır:
|
||||
- `DynamicCORS()`
|
||||
- `DynamicRateLimit()`
|
||||
6. `router.Setup(r)` ile endpointler yüklenir.
|
||||
|
||||
## Yetki Modeli
|
||||
|
||||
- `AuthRequired()`:
|
||||
- Access token doğrular
|
||||
- Context'e `user_id`, `email`, `username` yazar
|
||||
- `AdminRequired()`:
|
||||
- `users.is_admin` alanını kontrol eder
|
||||
- `POST/PUT/DELETE/PATCH` gibi mutating endpointlerde kullanılır
|
||||
|
||||
## Route Grupları
|
||||
|
||||
- Public: `/api/v1/auth/*`
|
||||
- Auth zorunlu (read + user işlemleri): `/api/v1/*`
|
||||
- Admin zorunlu (mutating yönetim işlemleri): `/api/v1/*` altında admin grubu
|
||||
- Swagger UI: `/swagger/*any`
|
||||
88
belgeler/testler.md
Normal file
88
belgeler/testler.md
Normal 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
159
belgeler/veritabani.md
Normal 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
50
build_windows.sh
Executable 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
BIN
builds/goaresv3_x64.exe
Executable file
Binary file not shown.
230
config/database.go
Normal file
230
config/database.go
Normal 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
3498
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
3477
docs/swagger.json
Normal file
3477
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
2285
docs/swagger.yaml
Normal file
2285
docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
67
go.mod
Normal file
67
go.mod
Normal 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
199
go.sum
Normal 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=
|
||||
57
main.go
Normal file
57
main.go
Normal 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
64
pkg/jwt/jwt.go
Normal 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
42
pkg/jwt/jwt_test.go
Normal 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
123
pkg/mailer/mailer.go
Normal 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
130
pkg/mailer/mailer_test.go
Normal 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
63
pkg/middleware/auth.go
Normal 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()
|
||||
}
|
||||
}
|
||||
59
pkg/middleware/auth_test.go
Normal file
59
pkg/middleware/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
68
pkg/middleware/cors_dynamic.go
Normal file
68
pkg/middleware/cors_dynamic.go
Normal 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()
|
||||
}
|
||||
}
|
||||
187
pkg/middleware/dynamic_policies_test.go
Normal file
187
pkg/middleware/dynamic_policies_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
29
pkg/middleware/log_flags.go
Normal file
29
pkg/middleware/log_flags.go
Normal 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...)
|
||||
}
|
||||
145
pkg/middleware/rate_limit_dynamic.go
Normal file
145
pkg/middleware/rate_limit_dynamic.go
Normal 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
|
||||
}
|
||||
60
pkg/swaggerui/initializer.go
Normal file
60
pkg/swaggerui/initializer.go
Normal 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
120
router/router.go
Normal 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
321
router/router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1
sudo nmcli con add type ethernet ifname
Normal file
1
sudo nmcli con add type ethernet ifname
Normal 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
1
tmp/build-errors.log
Normal 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
|
||||
23
yapi.md
Normal file
23
yapi.md
Normal 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>
|
||||
Reference in New Issue
Block a user