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