commit ec6d68154a72739532a4a1d3c96baa40bdc35a64 Author: ptrcnull Date: Sat Jan 22 21:30:58 2022 +0100 feat: Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0edbd8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:latest as builder + +LABEL maintainer="ptrcnull " + +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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..931c3d0 --- /dev/null +++ b/README.md @@ -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)) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2298a35 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2829ef3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/index.html b/index.html new file mode 100644 index 0000000..54c8191 --- /dev/null +++ b/index.html @@ -0,0 +1,49 @@ + + + + {{ .host }} + + + +{{ if .code }} +

https://{{ .host }}/{{ .code }}

+{{ else }} +
+ +
+{{ end }} + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..1a08c48 --- /dev/null +++ b/main.go @@ -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<= 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) + } +}