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