commit ee164a7aa7dd1559cfc4e35f3ac0d4b7f592b12a Author: SalehAskarzadeh Date: Tue Mar 31 10:21:07 2026 +0330 add db layer diff --git a/.env b/.env new file mode 100644 index 0000000..bd2aa43 --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +# Server +SERVER_PORT=9000 +BASE_URL=http://localhost:9000 + +#Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=SALEH@url-shortner +DB_NAME=urlshortner +DB_SSLMODE=disable + + +# Redis +REDIS_ADDR=localhost:6378 +REDIS_PASSWORD=SALEH@url-shortner +REDIS_DB=0 + +# App +DEFAULT_EXPIREY_HOURS=720 +API_KEY=my-secret-api-key \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..7f3d781 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/url-shortner.iml b/.idea/url-shortner.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/url-shortner.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..13ae5ee --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "net/http" + + "github.com/SalehGoML/url-shortner/internal/config" + "github.com/gin-gonic/gin" +) + +func main() { + cfg := config.Load() + log.Printf("Starting server on port %s", cfg.ServerPort) + + r := gin.Default() + + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "message": "URL Shortener is running", + }) + }) + + if err := r.Run(":" + cfg.ServerPort); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3cfc914 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + postgres: + image: postgres:16-alpine + container_name: urlshortner-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: SALEH@url-shortner + POSTGRES_DB: urlshortner + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d + + + redis: + image: redis:7-alpine + container_name: urlshortner-cache + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redisdata:/data + + +volumes: + pgdata: + redisdata: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a926807 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.gocasts.ir/msaskarzadeh/url-shortner.git + +go 1.25.0 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3e8d391 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + ServerPort string + BaseURL string + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + DBSSLMode string + RedisAddr string + RedisPassword string + RedisDB int + DefaultExpiryHrs int + APIKey string +} + +func Load() *Config { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using system env vars") + } + + redisDB, _ := strconv.Atoi(getEnv("REDIS_DB", "0")) + expiryHrs, _ := strconv.Atoi(getEnv("DEFAULT_EXPIRY_HOURS", "720")) + + return &Config{ + ServerPort: getEnv("SERVER_PORT", "9000"), + BaseURL: getEnv("BASE_URL", "http://localhost:9000"), + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnv("DB_PORT", "5432"), + DBUser: getEnv("DB_USER", "postgres"), + DBPassword: getEnv("DB_PASSWORD", "SALEH@url-shortner"), + DBName: getEnv("DB_NAME", "url-shortner"), + DBSSLMode: getEnv("DB_SSLMODE", "disable"), + RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: redisDB, + DefaultExpiryHrs: expiryHrs, + APIKey: getEnv("API_KEY", ""), + } +} + +func getEnv(key, fallback string) string { + if val, exists := os.LookupEnv(key); exists { + return val + } + + return fallback +} + +func (c *Config) DNS() string { + return "host=" + c.DBHost + + " port=" + c.DBPort + + " user=" + c.DBUser + + " password=" + c.DBPassword + + " dbname=" + c.DBName + + " sslmode=" + c.DBSSLMode + +} diff --git a/internal/model/url.go b/internal/model/url.go new file mode 100644 index 0000000..eab5a44 --- /dev/null +++ b/internal/model/url.go @@ -0,0 +1,48 @@ +package model + +import "time" + +type URL struct { + ID int64 `json:"id"` + ShortCode string `json:"short_code"` + OriginalURL string `json:"original"` + UserID string `json:"user_id"` + Clicks int64 `json:"clicks"` + IsActive bool `json:"is_active"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at,omitempty"` + UpdatedAt int64 `json:"updated_at"` +} + +type ClickLog struct { + ID int64 `json:"id"` + URLID string `json:"url_id"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Referer string `json:"referer"` + ClickedAt time.Time `json:"clicked_at"` +} + +type CreateURLRequest struct { + OriginalURL string `json:"original"` + CustomCode string `json:"custom_code,omitempty"` + ExpiryHours int `json:"expiry_hours,omitempty"` +} + +type CreateURLResponse struct { + ShortCode string `json:"short_code" binding:"required"` + ShortURL string `json:"short_url"` + OriginalURL string `json:"original_url"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type URLStats struct { + ShortCode string `json:"short_code"` + OriginalURL string `json:"original_url"` + Clicks int64 `json:"clicks"` + IsActive bool `json:"is_active"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at,omitempty"` + RecentClick []ClickLog `json:"recent_click"` +} diff --git a/internal/repository/url_repository.go b/internal/repository/url_repository.go new file mode 100644 index 0000000..8708bbb --- /dev/null +++ b/internal/repository/url_repository.go @@ -0,0 +1,171 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "git.gocasts.ir/msaskarzadeh/url-shortner.git/internal/model" +) + +type URLRepository struct { + db *sql.DB +} + +func NewURLRepository(dns string) (*URLRepository, error) { + db, err := sql.Open("postgres", dns) + if err != nil { + return nil, fmt.Errorf("failed to open DB: %w", err) + } + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to ping DB: %w", err) + } + + return &URLRepository{db: db}, nil +} + +func (r *URLRepository) Create(ctx context.Context, url *model.URL) error { + query := ` + INSERT INTO urls (short_code, original_url, user_id, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at` + + return r.db.QueryRowContext(ctx, query, + url.ShortCode, + url.OriginalURL, + url.UserID, + url.ExpiresAt, + ).Scan(&url.ID, &url.CreatedAt, &url.UpdatedAt) +} + +func (r *URLRepository) GetShortCode(ctx context.Context, code string) (*model.URL, error) { + query := ` + SELECT id, short_code, original_url, user_id, clicks, + is_active, created_at, expires_at, updated_at + FROM urls + WHERE short_code = $1` + + url := &model.URL{} + err := r.db.QueryRowContext(ctx, query, code).Scan( + &url.ID, + &url.ShortCode, + &url.OriginalURL, + &url.UserID, + &url.Clicks, + &url.IsActive, + &url.CreatedAt, + &url.ExpiresAt, + &url.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get URL: %w", err) + } + + return url, nil +} +func (r *URLRepository) IncrementClicks(ctx context.Context, id int64) error { + query := ` + UPDATE urls + SET clicks = clicks + 1, + updated_at = NOW() + WHERE id = $1` + _, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("failed to increment clicks: %w", err) + } + + return nil +} + +func (r *URLRepository) LogClicks(ctx context.Context, logEntry *model.ClickLog) error { + query := ` + INSERT INTO click_logs (url_id, ip_address, user_agent, referer) + VALUES ($1, $2, $3, $4) + RETURNING id, clicked_at` + + return r.db.QueryRowContext( + ctx, query, + logEntry.URLID, + logEntry.IPAddress, + logEntry.UserAgent, + logEntry.Referer, + ).Scan(&logEntry.ID, &logEntry.ClickedAt) +} + +func (r *URLRepository) GetClicks(ctx context.Context, urlID int64, limit int) ([]model.ClickLog, error) { + query := ` + SELECT id, url_id, ip_address, user_agent, referer, clicked_at + FROM click_logs + WHERE url_id = $1 + ORDER BY clicked_at DESC + LIMIT $2` + + rows, err := r.db.QueryContext(ctx, query, urlID, limit) + if err != nil { + return nil, fmt.Errorf("failed to fetch recent clicks: %w", err) + } + defer rows.Close() + + var logs []model.ClickLog + for rows.Next() { + var l model.ClickLog + if err := rows.Scan( + &l.ID, &l.URLID, &l.IPAddress, + &l.UserAgent, &l.Referer, &l.ClickedAt, + ); err != nil { + return nil, err + } + + logs = append(logs, l) + } + + return logs, nil +} + +func (r *URLRepository) DeactivateExpiredLinks(ctx context.Context) (int64, error) { + query := ` + UPDATE urls + SET is_active = false, + updated_at = NOW() + WHERE expires_at IS NOT NULL + AND expires_at < NOW() + AND is_active = true` + + result, err := r.db.ExecContext(ctx, query) + if err != nil { + return 0, fmt.Errorf("failed to deactivate expired links: %w", err) + } + + affected, err := result.RowsAffected() + return affected, err +} + +func (r *URLRepository) CheckShortExists(ctx context.Context, code string) (bool, error) { + query := `SELECT 1 FROM urls WHERE short_code = $1` + + var exists int + err := r.db.QueryRowContext(ctx, query, code).Scan(&exists) + + if err == sql.ErrNoRows { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("failed to check code: %w", err) + } + + return true, nil +} diff --git a/migrations/001_create_urls.sql b/migrations/001_create_urls.sql new file mode 100644 index 0000000..ffba994 --- /dev/null +++ b/migrations/001_create_urls.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS urls ( + id BIGSERIAL PRIMARY KEY, + short_code VARCHAR(10) NOT NULL UNIQUE, + original_url TEXT NOT NULL, + user_id VARCHAR(100) DEFAULT 'anonymous', + clicks BIGINT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP, + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_urls_short_code ON urls(short_code); + +CREATE INDEX idx_urls_active_expiry ON urls(is_active, expires_at); + +CREATE TABLE IF NOT EXISTS click_logs ( + id BIGSERIAL PRIMARY KEY, + url_id BIGINT REFERENCES urls(id) ON DELETE CASCADE, + ip_address VARCHAR(45), + user_agent TEXT, + referer TEXT, + clicked_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_click_logs_url_id ON click_logs(url_id); +CREATE INDEX idx_click_logs_time ON click_logs(clicked_at); \ No newline at end of file