first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
build/
|
||||||
8
Dockerfile
Normal file
8
Dockerfile
Normal 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
24
LICENSE
Normal 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>
|
||||||
22
compose.yml
Normal file
22
compose.yml
Normal 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
13
deployment/Dockerfile
Normal 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
35
deployment/compose.yml
Normal 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
17
go.mod
Normal 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
27
go.sum
Normal 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
36
internal/cache/cache.go
vendored
Normal 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
51
internal/db/database.go
Normal 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
|
||||||
|
}
|
||||||
7
internal/db/migrations/migrations.go
Normal file
7
internal/db/migrations/migrations.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func
|
||||||
105
internal/shortener/handler.go
Normal file
105
internal/shortener/handler.go
Normal 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
53
main.go
Normal 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
78
utils/config.go
Normal 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
29
utils/hash.go
Normal 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
25
utils/utils.go
Normal 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
114
web/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user