first commit

This commit is contained in:
ferdzo
2025-08-06 21:12:23 +02:00
commit b5115db152
18 changed files with 647 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
build/

8
Dockerfile Normal file
View File

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

24
LICENSE Normal file
View File

@@ -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 <https://unlicense.org>

1
README.md Normal file
View File

@@ -0,0 +1 @@
## URL Shortener in Go

22
compose.yml Normal file
View File

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

13
deployment/Dockerfile Normal file
View File

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

35
deployment/compose.yml Normal file
View File

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

17
go.mod Normal file
View File

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

27
go.sum Normal file
View File

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

36
internal/cache/cache.go vendored Normal file
View File

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

51
internal/db/database.go Normal file
View File

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

View File

@@ -0,0 +1,7 @@
package migrations
import (
"github.com/jackc/pgx/v5"
)
func

View File

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

53
main.go Normal file
View File

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

78
utils/config.go Normal file
View File

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

29
utils/hash.go Normal file
View File

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

25
utils/utils.go Normal file
View File

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

114
web/index.html Normal file
View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferurl - URL Shortener</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
.container {
border: 1px solid #ddd;
border-radius: 5px;
padding: 20px;
margin-top: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
form {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 5px;
font-weight: bold;
}
input[type="url"] {
padding: 8px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #45a049;
}
.result {
margin-top: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
display: none;
}
</style>
</head>
<body>
<h1>Ferurl - URL Shortener</h1>
<div class="container">
<form id="urlForm">
<label for="longUrl">Enter your long URL:</label>
<input type="url" id="longUrl" name="longUrl" required placeholder="https://example.com/your/very/long/url/goes/here">
<button type="submit">Shorten URL</button>
</form>
<div id="result" class="result">
<p>Your shortened URL: <a id="shortUrl" href="#" target="_blank"></a></p>
</div>
</div>
<script>
document.getElementById('urlForm').addEventListener('submit', async function(e) {
e.preventDefault();
const longUrl = document.getElementById('longUrl').value;
try {
const response = await fetch('http://localhost:8080/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: longUrl })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Show the result
const resultDiv = document.getElementById('result');
const shortUrlLink = document.getElementById('shortUrl');
shortUrlLink.href = `http://localhost:8080/${data.shortCode}`;
shortUrlLink.textContent = `http://localhost:8080/${data.shortCode}`;
resultDiv.style.display = 'block';
} catch (error) {
console.error('Error:', error);
alert('Failed to shorten URL. Please try again.');
}
});
</script>
</body>
</html>