mirror of
https://github.com/aykhans/oh-my-url.git
synced 2025-07-02 00:56:47 +00:00
🎉 first commit
This commit is contained in:
10
app/config/.env.example
Normal file
10
app/config/.env.example
Normal 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
78
app/config/settings.go
Normal 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
48
app/db/base.go
Normal 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
1
app/db/cassandra.go
Normal file
@ -0,0 +1 @@
|
||||
package db
|
1
app/db/mongodb.go
Normal file
1
app/db/mongodb.go
Normal file
@ -0,0 +1 @@
|
||||
package db
|
72
app/db/postgres.go
Normal file
72
app/db/postgres.go
Normal 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
27
app/http_handlers/base.go
Normal 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)
|
||||
}
|
75
app/http_handlers/url_create.go
Normal file
75
app/http_handlers/url_create.go
Normal 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)
|
||||
}
|
||||
}
|
26
app/http_handlers/url_forward.go
Normal file
26
app/http_handlers/url_forward.go
Normal 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
37
app/main.go
Normal 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
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
82
app/templates/index.html
Normal 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
26
app/utils/short_key.go
Normal 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
19
app/utils/templates.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user