feat: Initial commit
This commit is contained in:
commit
ec6d68154a
6 changed files with 283 additions and 0 deletions
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
FROM golang:latest as builder
|
||||||
|
|
||||||
|
LABEL maintainer="ptrcnull <docker@ptrcnull.me>"
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server main.go
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /src/server .
|
||||||
|
|
||||||
|
CMD ["/app/server"]
|
9
README.md
Normal file
9
README.md
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# shorten
|
||||||
|
|
||||||
|
> simple url shortener
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Environmental variables:
|
||||||
|
- `SHORTEN_HOST` - hostname
|
||||||
|
- `POSTGRES_URI` - lib/pq connection string (see [here](https://pkg.go.dev/github.com/lib/pq#section-documentation))
|
8
go.mod
Normal file
8
go.mod
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
module git.ddd.rip/ptrcnull/shorten
|
||||||
|
|
||||||
|
go 1.16
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
|
||||||
|
github.com/lib/pq v1.10.4
|
||||||
|
)
|
4
go.sum
Normal file
4
go.sum
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
|
||||||
|
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
49
index.html
Normal file
49
index.html
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ .host }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #111111;
|
||||||
|
color: #dddddd;
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 32px);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 32px);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
font-size: 30px;
|
||||||
|
width: 70vw;
|
||||||
|
border: none;
|
||||||
|
color: #dddddd;
|
||||||
|
background-color: {{ if .error }}rgba(255, 0, 0, 0.25){{ else }}rgba(255, 255, 255, 0.03){{ end }};
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
font-family: sans-serif;
|
||||||
|
color: #dddddd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{ if .code }}
|
||||||
|
<h1><a href="https://{{ .host }}/{{ .code }}">https://{{ .host }}/{{ .code }}</a></h1>
|
||||||
|
{{ else }}
|
||||||
|
<form method="POST" action="/" autocomplete="off" autocapitalize="off">
|
||||||
|
<input type="text" {{ with .error }}placeholder="{{ . }}"{{ end }} name="url" autocomplete="off" autocapitalize="off">
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
</body>
|
||||||
|
</html>
|
195
main.go
Normal file
195
main.go
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
const (
|
||||||
|
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||||
|
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||||
|
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
|
||||||
|
)
|
||||||
|
|
||||||
|
var src = rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
|
func GenerateCode() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
for i, cache, remain := 5, src.Int63(), letterIdxMax; i >= 0; {
|
||||||
|
if remain == 0 {
|
||||||
|
cache, remain = src.Int63(), letterIdxMax
|
||||||
|
}
|
||||||
|
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
|
||||||
|
b[i] = letterBytes[idx]
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
cache >>= letterIdxBits
|
||||||
|
remain--
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed index.html
|
||||||
|
var tmplSource string
|
||||||
|
var tmpl *template.Template
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Println("Booting up...")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
tmpl, err = template.New("index.html").Parse(tmplSource)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", os.Getenv("POSTGRES_URI"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS urls (
|
||||||
|
code text not null,
|
||||||
|
url text not null,
|
||||||
|
created_at timestamp with time zone not null,
|
||||||
|
author text not null,
|
||||||
|
hits bigint not null
|
||||||
|
);`)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(http.ListenAndServe("127.0.0.1:4488", &Handler{db: db}))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path == "/" {
|
||||||
|
if req.Method == "POST" {
|
||||||
|
h.CreateHandler(wr, req)
|
||||||
|
} else {
|
||||||
|
h.HomepageHandler(wr, req)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RedirectHandler(wr, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HomepageHandler(wr http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Println("HomepageHandler")
|
||||||
|
|
||||||
|
url := req.URL.Query().Get("url")
|
||||||
|
if url != "" {
|
||||||
|
code, err := h.GetCode(url, req.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
wr.WriteHeader(http.StatusInternalServerError)
|
||||||
|
wr.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
wr.Write([]byte("https://" + os.Getenv("SHORTEN_HOST") + "/" + code))
|
||||||
|
}
|
||||||
|
|
||||||
|
Render(wr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateHandler(wr http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Println("CreateHandler")
|
||||||
|
|
||||||
|
req.ParseForm()
|
||||||
|
code, err := h.GetCode(req.Form.Get("url"), req.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
Render(wr, map[string]string{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
Render(wr, map[string]string{"code": code})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RedirectHandler(wr http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Println("RedirectHandler")
|
||||||
|
|
||||||
|
code := req.URL.Path[1:]
|
||||||
|
var url string
|
||||||
|
var hits uint64
|
||||||
|
if err := h.db.QueryRow(`SELECT url, hits FROM urls WHERE code = $1`, code).Scan(&url, &hits); err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
log.Println("hits query error:", err)
|
||||||
|
}
|
||||||
|
wr.Header().Set("Location", "/")
|
||||||
|
wr.WriteHeader(http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, _ = h.db.Exec(`UPDATE urls SET hits = $1 WHERE code = $2`, hits + 1, code)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wr.Header().Set("Location", url)
|
||||||
|
wr.WriteHeader(http.StatusMovedPermanently)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CodeExists(code string) bool {
|
||||||
|
var exists bool
|
||||||
|
err := h.db.QueryRow(`SELECT 1 FROM urls WHERE code = $1 LIMIT 1`, code).Scan(&exists)
|
||||||
|
if err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
log.Println("check code error:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetCode(url string, ip string) (string, error) {
|
||||||
|
log.Printf("url: %#v\n", url)
|
||||||
|
if !strings.HasPrefix(url, "http") || !govalidator.IsURL(url) {
|
||||||
|
return "", fmt.Errorf("invalid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var code string
|
||||||
|
err := h.db.QueryRow(`SELECT code FROM urls WHERE url = $1 LIMIT 1`, url).Scan(&code)
|
||||||
|
if err == nil {
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return "", fmt.Errorf("query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code = GenerateCode()
|
||||||
|
for h.CodeExists(code) {
|
||||||
|
code = GenerateCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Exec(
|
||||||
|
`INSERT INTO urls (code, url, created_at, author, hits) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
code, url, time.Now(), ip, 0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(wr http.ResponseWriter, data map[string]string) {
|
||||||
|
wr.Header().Set("Content-Type", "text/html")
|
||||||
|
data["host"] = os.Getenv("SHORTEN_HOST")
|
||||||
|
err := tmpl.Execute(wr, data)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error writing template:", err)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue