From b5115db1520bc415c62df7e9382b34930fd364e6 Mon Sep 17 00:00:00 2001 From: ferdzo Date: Wed, 6 Aug 2025 21:12:23 +0200 Subject: [PATCH] first commit --- .gitignore | 2 + Dockerfile | 8 ++ LICENSE | 24 ++++++ README.md | 1 + compose.yml | 22 ++++++ deployment/Dockerfile | 13 +++ deployment/compose.yml | 35 ++++++++ go.mod | 17 ++++ go.sum | 27 +++++++ internal/cache/cache.go | 36 +++++++++ internal/db/database.go | 51 ++++++++++++ internal/db/migrations/migrations.go | 7 ++ internal/shortener/handler.go | 105 ++++++++++++++++++++++++ main.go | 53 +++++++++++++ utils/config.go | 78 ++++++++++++++++++ utils/hash.go | 29 +++++++ utils/utils.go | 25 ++++++ web/index.html | 114 +++++++++++++++++++++++++++ 18 files changed, 647 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compose.yml create mode 100644 deployment/Dockerfile create mode 100644 deployment/compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cache/cache.go create mode 100644 internal/db/database.go create mode 100644 internal/db/migrations/migrations.go create mode 100644 internal/shortener/handler.go create mode 100644 main.go create mode 100644 utils/config.go create mode 100644 utils/hash.go create mode 100644 utils/utils.go create mode 100644 web/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de410db --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc5b134 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:latest as build + +WORKDIR /app +COPY . . +RUN go build -o main . + +FROM scratch +COPY --from=build /app/main /usr/local/bin/main diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..caa4dcd --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +## URL Shortener in Go diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..5b2ba3e --- /dev/null +++ b/compose.yml @@ -0,0 +1,22 @@ +services: + redis: + image: redis:8.2-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + ports: + - 6379:6379 + + db: + image: postgres:17.5 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USER} + POSTGRES_DB: ${DB_NAME} + ports: + - 5432:5432 + + # app: + # build: . + # env_file: + # - .env + # ports: + # - 3000:3000 diff --git a/deployment/Dockerfile b/deployment/Dockerfile new file mode 100644 index 0000000..005495b --- /dev/null +++ b/deployment/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:latest as build + +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o ferurl + +FROM alpine:latest +WORKDIR /app +COPY --from=build /app/ferurl /app/ferurl +COPY web/ /app/web/ + +EXPOSE 3000 +ENTRYPOINT [ "/app/ferurl" ] diff --git a/deployment/compose.yml b/deployment/compose.yml new file mode 100644 index 0000000..ef554fa --- /dev/null +++ b/deployment/compose.yml @@ -0,0 +1,35 @@ +services: + redis: + image: redis:8.2-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + ports: + - 6379:6379 + + db: + image: postgres:17.5 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USER} + POSTGRES_DB: ${DB_NAME} + ports: + - 5432:5432 + volumes: + - ./postgres-init:/docker-entrypoint-initdb.d + - postgres-data:/var/lib/postgresql/data + command: postgres -c listen_addresses='*' -c log_statement=all + + app: + build: . + env_file: + - .env + ports: + - 3000:3000 + depends_on: + - redis + - db + restart: unless-stopped + environment: + - REDIS_HOST=redis + - DB_HOST=db +volumes: + postgres-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9b3dacf --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/ferdzo/ferurl + +go 1.24.5 + +require github.com/go-chi/chi/v5 v5.2.2 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/redis/go-redis/v9 v9.12.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..54e71c9 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.12.0 h1:XlVPGlflh4nxfhsNXPA8Qp6EmEfTo0rp8oaBzPipXnU= +github.com/redis/go-redis/v9 v9.12.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..4bcccce --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,36 @@ +package cache + +import ( + "context" + + "github.com/ferdzo/ferurl/utils" + "github.com/redis/go-redis/v9" +) + +var ctx = context.Background() + +type Cache struct { + client *redis.Client +} + +func NewRedisClient(config utils.RedisConfig) (*Cache, error) { + client := redis.NewClient(&redis.Options{ + Addr: utils.RedisUrl(), + Password: config.Password, + DB: 0, + }) + + if err := client.Ping(ctx).Err(); err != nil { + return nil, err + } + + return &Cache{client: client}, nil +} + +func (c *Cache) Get(key string) (string, error) { + return c.client.Get(ctx, key).Result() +} + +func (c *Cache) Set(key string, value string) error { + return c.client.Set(ctx, key, value, 0).Err() +} diff --git a/internal/db/database.go b/internal/db/database.go new file mode 100644 index 0000000..27f8c5e --- /dev/null +++ b/internal/db/database.go @@ -0,0 +1,51 @@ +package db + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/ferdzo/ferurl/utils" + "github.com/jackc/pgx/v5" +) + +type Database struct { + client *pgx.Conn +} + +func NewDatabaseClient(config utils.DatabaseConfig) (*Database, error) { + conn, err := pgx.Connect(context.Background(), utils.DatabaseUrl()) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + return nil, err + } + + return &Database{client: conn}, nil +} + +func (d *Database) InsertNewURL(shorturl string, url string) error { + timeNow := time.Now() + _, err := d.client.Exec(context.Background(), "INSERT INTO urls (shorturl, url, created_at) VALUES ($1, $2, $3)", shorturl, url, timeNow) + if err != nil { + return fmt.Errorf("failed to insert URL into database: %w", err) + } + return nil +} + +func (d *Database) DeleteURL(shorturl string) error { + _, err := d.client.Exec(context.Background(), "DELETE FROM urls WHERE shorturl = $1", shorturl) + if err != nil { + return fmt.Errorf("failed to delete URL from database: %w", err) + } + return nil +} + +func (d *Database) GetURL(shorturl string) (string, error) { + var url string + err := d.client.QueryRow(context.Background(), "SELECT url FROM urls WHERE shorturl = $1", shorturl).Scan(&url) + if err != nil { + return "", fmt.Errorf("failed to retrieve URL from database: %w", err) + } + return url, nil +} diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go new file mode 100644 index 0000000..8722167 --- /dev/null +++ b/internal/db/migrations/migrations.go @@ -0,0 +1,7 @@ +package migrations + +import ( + "github.com/jackc/pgx/v5" +) + +func \ No newline at end of file diff --git a/internal/shortener/handler.go b/internal/shortener/handler.go new file mode 100644 index 0000000..53c5de5 --- /dev/null +++ b/internal/shortener/handler.go @@ -0,0 +1,105 @@ +package shortener + +import ( + "fmt" + "net/http" + + "github.com/ferdzo/ferurl/internal/cache" + "github.com/ferdzo/ferurl/internal/db" + + "github.com/ferdzo/ferurl/utils" + "github.com/go-chi/chi/v5" +) + +type Service struct { + cache *cache.Cache + database *db.Database +} + +type Handler struct { + service *Service +} + +func NewService(redisConfig utils.RedisConfig, databaseConfig utils.DatabaseConfig) (*Service, error) { + redisClient, err := cache.NewRedisClient(redisConfig) + if err != nil { + return nil, err + } + databaseClient, err := db.NewDatabaseClient(databaseConfig) + if err != nil { + return nil, err + } + + return &Service{cache: redisClient, database: databaseClient}, nil +} + +func NewHandler(service *Service) (*Handler, error) { + if service == nil { + return nil, fmt.Errorf("service is nil") + } + + return &Handler{service: service}, nil +} + +func (h *Handler) CreateShortURL(w http.ResponseWriter, r *http.Request) { + longUrl := r.FormValue("url") + + if longUrl == "" { + http.Error(w, "Long URL is required", http.StatusBadRequest) + return + } + if !utils.IsValidUrl(longUrl) { + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + shortUrl := generateShortUrl(longUrl) + + http.Redirect(w, r, shortUrl, http.StatusSeeOther) +} + +func (h *Handler) GetUrl(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "key") + if id == "" { + http.Error(w, "ID is required", http.StatusBadRequest) + return + } + + shortUrl, err := h.fetchUrl(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, shortUrl, http.StatusSeeOther) +} + +func generateShortUrl(url string) string { + shortUrl := utils.GenerateUrlHash(url)[:7] + + return shortUrl +} + +func (h *Handler) fetchUrl(id string) (string, error) { + if url, err := h.service.fetchUrlFromCache(id); err == nil { + return url, nil + } + if url, err := h.service.fetchUrlFromDatabase(id); err == nil { + return url, nil + } + return "", fmt.Errorf("URL not found") +} + +func (s *Service) fetchUrlFromCache(shortUrl string) (string, error) { + if url, err := s.cache.Get(shortUrl); err == nil { + return url, nil + } + return "", fmt.Errorf("URL not found") +} + +func (s *Service) fetchUrlFromDatabase(id string) (string, error) { + if url, err := s.database.GetURL(id); err == nil { + return url, nil + } + return "", fmt.Errorf("URL not found") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2e406f2 --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "net/http" + + shortner "github.com/ferdzo/ferurl/internal/shortener" + "github.com/ferdzo/ferurl/utils" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + fmt.Println("Welcome to ferurl, a simple URL shortener!") + + redisConfig, err := utils.LoadRedisConfig() + if err != nil { + fmt.Println("Error loading Redis config:", err) + return + } + dbConfig, err := utils.LoadDbConfig() + if err != nil { + fmt.Println("Error loading DB config:", err) + return + } + + s, err := shortner.NewService(redisConfig, dbConfig) + if err != nil { + fmt.Println("Error creating service:", err) + return + } + + h, err := shortner.NewHandler(s) + if err != nil { + fmt.Println("Error creating handler:", err) + return + } + initServer(*h) +} + +func initServer(h shortner.Handler) { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Welcome to ferurl!") + }) + fmt.Println("Server started on port 3000") + r.Post("/create", h.CreateShortURL) + r.Get("/{key}", h.GetUrl) + http.FileServer(http.Dir("web")) + + http.ListenAndServe(":3000", r) +} diff --git a/utils/config.go b/utils/config.go new file mode 100644 index 0000000..78c58f7 --- /dev/null +++ b/utils/config.go @@ -0,0 +1,78 @@ +package utils + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +type RedisConfig struct { + Host string + Port string + Password string + Database string +} + +type DatabaseConfig struct { + Host string + Port string + Username string + Password string + Name string +} + +func LoadEnv() error { + err := godotenv.Load() + if err != nil { + return fmt.Errorf("failed to load .env file: %w", err) + } + return nil + +} + +func GetEnv(key string, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +func DatabaseUrl() string { + return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", GetEnv("DB_USERNAME", "postgres"), + GetEnv("DB_PASSWORD", "postgres"), GetEnv("DB_HOST", "localhost"), GetEnv("DB_PORT", "5432"), GetEnv("DB_NAME", "postgres")) +} + +func RedisUrl() string { + return fmt.Sprintf("%s:%s", GetEnv("REDIS_HOST", "localhost"), GetEnv("REDIS_PORT", "6379")) +} + +func LoadRedisConfig() (RedisConfig, error) { + err := LoadEnv() + if err != nil { + return RedisConfig{}, err + } + + return RedisConfig{ + Host: GetEnv("REDIS_HOST", "localhost"), + Port: GetEnv("REDIS_PORT", "6379"), + Password: GetEnv("REDIS_PASSWORD", ""), + Database: GetEnv("REDIS_DB", "0"), + }, nil +} + +func LoadDbConfig() (DatabaseConfig, error) { + err := LoadEnv() + if err != nil { + return DatabaseConfig{}, err + } + + return DatabaseConfig{ + Host: GetEnv("POSTGRES_HOST", "localhost"), + Port: GetEnv("POSTGRES_PORT", "5432"), + Username: GetEnv("POSTGRES_USER", "postgres"), + Password: GetEnv("POSTGRES_PASSWORD", "password"), + Name: GetEnv("POSTGRES_DB", "ferurl"), + }, nil +} diff --git a/utils/hash.go b/utils/hash.go new file mode 100644 index 0000000..1dd11a5 --- /dev/null +++ b/utils/hash.go @@ -0,0 +1,29 @@ +package utils + +import ( + "crypto/sha256" + "fmt" +) + +const base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +func GenerateUrlHash(url string) string { + encoded := EncodeToBase62(url) + hash := sha256.Sum256([]byte(encoded)) + + return fmt.Sprintf("%x", hash) +} + +func EncodeToBase62(url string) string { + hash := sha256.Sum256([]byte(url)) + + encoded := "" + for _, b := range hash[:] { + encoded += string(base62[int(b)%62]) + } + if len(encoded) > 6 { + encoded = encoded[:6] + } + + return encoded +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..3534b5c --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,25 @@ +package utils + +import ( + "net/url" + "regexp" + "strings" +) + +func IsValidUrl(s string) bool { + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { + return false + } + domainRegex := `^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$` + parsedUrl, err := url.ParseRequestURI(s) + if err != nil { + return false + } + matched, err := regexp.MatchString(domainRegex, parsedUrl.Host) + if err != nil || !matched { + return false + } + + return true + +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5ad5df5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,114 @@ + + + + + + Ferurl - URL Shortener + + + +

Ferurl - URL Shortener

+ +
+
+ + + +
+ +
+

Your shortened URL:

+
+
+ + + +