feat: Initial commit

This commit is contained in:
ptrcnull 2022-01-22 21:30:58 +01:00
commit ec6d68154a
6 changed files with 283 additions and 0 deletions

18
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}
}