🎉 first commit

This commit is contained in:
2024-01-19 16:11:54 +04:00
commit f59cb4f927
22 changed files with 667 additions and 0 deletions

10
app/config/.env.example Normal file
View File

@ -0,0 +1,10 @@
DB=postgres
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_HOST=ohmyurl-postgresql
POSTGRES_PORT=5432
LISTEN_PORT_CREATE=8080
LISTEN_PORT_FORWARD=8081
FORWARD_DOMAIN=http://localhost/
CREATE_DOMAIN=http://127.0.0.1

78
app/config/settings.go Normal file
View File

@ -0,0 +1,78 @@
package config
import (
"os"
"strings"
)
type DBName string
const (
Postgres DBName = "postgres"
MongoDB DBName = "mongodb"
Cassandra DBName = "cassandra"
)
type AppConfig struct {
LISTEN_PORT_CREATE string
LISTEN_PORT_FORWARD string
FORWARD_DOMAIN string
CREATE_DOMAIN string
}
type PostgresConfig struct {
USER string
PASSWORD string
HOST string
PORT string
DBNAME string
}
func GetAppConfig() *AppConfig {
return &AppConfig{
LISTEN_PORT_CREATE: GetEnvOrPanic("LISTEN_PORT_CREATE"),
LISTEN_PORT_FORWARD: GetEnvOrPanic("LISTEN_PORT_FORWARD"),
FORWARD_DOMAIN: GetEnvOrPanic("FORWARD_DOMAIN"),
CREATE_DOMAIN: GetEnvOrPanic("CREATE_DOMAIN"),
}
}
func GetPostgresConfig() *PostgresConfig {
return &PostgresConfig{
USER: GetEnvOrPanic("POSTGRES_USER"),
PASSWORD: GetEnvOrPanic("POSTGRES_PASSWORD"),
HOST: GetEnvOrPanic("POSTGRES_HOST"),
PORT: GetEnvOrDefault("POSTGRES_PORT", "5432"),
DBNAME: GetEnvOrPanic("POSTGRES_DB"),
}
}
func GetDB() DBName {
dbName := strings.ToLower(GetEnvOrPanic("DB"))
switch dbName {
case "postgres":
return Postgres
case "mongodb":
return MongoDB
case "cassandra":
return Cassandra
default:
panic("Unknown database")
}
}
func GetEnvOrDefault(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func GetEnvOrPanic(key string) string {
value := os.Getenv(key)
if value == "" {
panic("Environment variable " + key + " is not set")
}
return value
}

48
app/db/base.go Normal file
View File

@ -0,0 +1,48 @@
package db
import (
"fmt"
"github.com/aykhans/oh-my-url/app/config"
gormPostgres "gorm.io/driver/postgres"
"gorm.io/gorm"
"time"
)
type DB interface {
Init()
CreateURL(url string) (string, error)
GetURL(key string) (string, error)
}
func GetDB() DB {
db := config.GetDB()
switch db {
case "postgres":
postgresConf := config.GetPostgresConfig()
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s",
postgresConf.HOST,
postgresConf.USER,
postgresConf.PASSWORD,
postgresConf.DBNAME,
postgresConf.PORT,
"disable",
"UTC",
)
var db *gorm.DB
var err error
for i := 0; i < 5; i++ {
db, err = gorm.Open(gormPostgres.Open(dsn), &gorm.Config{})
if err == nil {
break
}
time.Sleep(3 * time.Second)
}
if err != nil {
panic(err)
}
return &Postgres{gormDB: db}
}
return nil
}

1
app/db/cassandra.go Normal file
View File

@ -0,0 +1 @@
package db

1
app/db/mongodb.go Normal file
View File

@ -0,0 +1 @@
package db

72
app/db/postgres.go Normal file
View File

@ -0,0 +1,72 @@
package db
import (
// "github.com/aykhans/oh-my-url/app/config"
"gorm.io/gorm"
)
type Postgres struct {
gormDB *gorm.DB
}
type url struct {
ID uint `gorm:"primaryKey"`
Key string `gorm:"unique;not null;size:15;default:null"`
URL string `gorm:"not null;default:null"`
}
func (p *Postgres) Init() {
err := p.gormDB.AutoMigrate(&url{})
if err != nil {
panic("failed to migrate database")
}
tx := p.gormDB.Exec(urlKeyCreateTrigger)
if tx.Error != nil {
panic("failed to create trigger")
}
}
func (p *Postgres) CreateURL(mainUrl string) (string, error) {
url := url{URL: mainUrl}
tx := p.gormDB.Create(&url)
if tx.Error != nil {
return "", tx.Error
}
return url.Key, nil
}
func (p *Postgres) GetURL(key string) (string, error) {
var result url
tx := p.gormDB.Where("key = ?", key).First(&result)
if tx.Error != nil {
return "", tx.Error
}
return result.URL, nil
}
const urlKeyCreateTrigger = `
CREATE OR REPLACE FUNCTION generate_url_key()
RETURNS TRIGGER AS $$
DECLARE
key_characters TEXT := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
key TEXT := '';
base INT := LENGTH(key_characters);
n INT := NEW.id;
BEGIN
WHILE n > 0 LOOP
n := n - 1;
key := SUBSTRING(key_characters FROM (n % base) + 1 FOR 1) || key;
n := n / base;
END LOOP;
NEW.key := key;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_generate_url_key on "public"."urls";
CREATE TRIGGER trigger_generate_url_key
BEFORE INSERT ON urls
FOR EACH ROW
EXECUTE FUNCTION generate_url_key();
`

27
app/http_handlers/base.go Normal file
View File

@ -0,0 +1,27 @@
package httpHandlers
import (
"github.com/aykhans/oh-my-url/app/db"
"github.com/aykhans/oh-my-url/app/utils"
"log"
"net/http"
)
type HandlerCreate struct {
DB db.DB
ForwardDomain string
}
type HandlerForward struct {
DB db.DB
CreateDomain string
}
func FaviconHandler(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, utils.GetTemplatePaths("favicon.ico")[0])
}
func InternalServerError(w http.ResponseWriter, err error) {
log.Fatal(err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}

View File

@ -0,0 +1,75 @@
package httpHandlers
import (
"github.com/aykhans/oh-my-url/app/utils"
"html/template"
"net/http"
netUrl "net/url"
"regexp"
)
type CreateData struct {
ShortedURL string
MainURL string
Error string
}
func (hl *HandlerCreate) UrlCreate(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
tmpl, err := template.ParseFiles(utils.GetTemplatePaths("index.html")...)
if err != nil {
InternalServerError(w, err)
return
}
switch r.Method {
case http.MethodGet:
err = tmpl.Execute(w, nil)
if err != nil {
InternalServerError(w, err)
return
}
case http.MethodPost:
url := r.FormValue("url")
urlRegex := regexp.MustCompile(`^(http|https)://[a-zA-Z0-9.-]+(?:\:[0-9]+)?(?:/[^\s]*)?$`)
isValidUrl := urlRegex.MatchString(url)
if !isValidUrl {
data := CreateData{
MainURL: url,
Error: "Invalid URL",
}
err = tmpl.Execute(w, data)
if err != nil {
InternalServerError(w, err)
}
return
}
key, err := hl.DB.CreateURL(url)
if err != nil {
InternalServerError(w, err)
return
}
shortedURL, err := netUrl.JoinPath(hl.ForwardDomain, key)
if err != nil {
InternalServerError(w, err)
return
}
data := CreateData{
ShortedURL: shortedURL,
MainURL: url,
}
err = tmpl.Execute(w, data)
if err != nil {
InternalServerError(w, err)
return
}
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}

View File

@ -0,0 +1,26 @@
package httpHandlers
import (
"net/http"
"strings"
)
func (hl *HandlerForward) UrlForward(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
segments := strings.Split(path, "/")
if len(segments) > 2 {
http.NotFound(w, r)
return
} else if segments[1] == "" {
http.Redirect(w, r, hl.CreateDomain, http.StatusMovedPermanently)
return
}
key := segments[1]
url, err := hl.DB.GetURL(key)
if err != nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, url, http.StatusMovedPermanently)
}

37
app/main.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
"github.com/aykhans/oh-my-url/app/config"
"github.com/aykhans/oh-my-url/app/db"
"github.com/aykhans/oh-my-url/app/http_handlers"
"net/http"
"sync"
)
func main() {
config := config.GetAppConfig()
dbCreate := db.GetDB()
dbCreate.Init()
handlerCreate := httpHandlers.HandlerCreate{DB: dbCreate, ForwardDomain: config.FORWARD_DOMAIN}
urlCreateMux := http.NewServeMux()
urlCreateMux.HandleFunc("/", handlerCreate.UrlCreate)
urlCreateMux.HandleFunc("/favicon.ico", httpHandlers.FaviconHandler)
dbRead := db.GetDB()
handlerForward := httpHandlers.HandlerForward{DB: dbRead, CreateDomain: config.CREATE_DOMAIN}
urlReadMux := http.NewServeMux()
urlReadMux.HandleFunc("/", handlerForward.UrlForward)
urlReadMux.HandleFunc("/favicon.ico", httpHandlers.FaviconHandler)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
panic(http.ListenAndServe(":"+config.LISTEN_PORT_CREATE, urlCreateMux))
}()
go func() {
defer wg.Done()
panic(http.ListenAndServe(":"+config.LISTEN_PORT_FORWARD, urlReadMux))
}()
wg.Wait()
}

BIN
app/templates/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

82
app/templates/index.html Normal file
View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Oh My URL</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
.container {
max-width: 600px;
margin: 50px auto;
background-color: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
}
form {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 8px;
}
input[type="url"] {
padding: 8px;
margin-bottom: 15px;
border-radius: 3px;
border: 1px solid #ccc;
}
button {
padding: 10px 15px;
width: 20%;
margin: 0 auto;
font-size: medium;
border: none;
border-radius: 3px;
background-color: #007bff;
color: #fff;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #0056b3;
}
button:active {
transform: scale(0.98);
}
</style>
</head>
<body>
<div class="container">
<h1>Oh My URL!</h1>
<form action="/" method="post">
{{ if .Error }}
<label for="urlInput" style="color: red;">{{ .Error }}</label>
{{ end }}
<input type="url" id="urlInput" name="url" placeholder="https://example.com/" value="{{ if .MainURL }}{{ .MainURL }}{{ end }}" required>
{{ if .ShortedURL }}
<label for="urlOutput">Shortened URL: <a href="{{.ShortedURL}}" target="_blank">{{.ShortedURL}}</a></label>
{{ end }}
<button type="submit">Get URL</button>
</form>
</div>
</body>
</html>

26
app/utils/short_key.go Normal file
View File

@ -0,0 +1,26 @@
package utils
import (
"strings"
)
const keyCharacters string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func GenerateKey(n int) string {
var result strings.Builder
base := len(keyCharacters)
for n > 0 {
n--
result.WriteByte(keyCharacters[n%base])
n /= base
}
key := result.String()
runes := []rune(key)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}

19
app/utils/templates.go Normal file
View File

@ -0,0 +1,19 @@
package utils
import (
"os"
"path/filepath"
)
func GetTemplatePaths(filenames ...string) []string {
dir, err := os.Getwd()
if err != nil {
panic(err)
}
templatePath := filepath.Join(dir, "app", "templates")
for i, filename := range filenames {
filenames[i] = filepath.Join(templatePath, filename)
}
return filenames
}