real initial commit
This commit is contained in:
parent
bc4179cca7
commit
d773a632f6
5 changed files with 647 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
see.db-shm
|
||||
see
|
||||
see.db
|
||||
see.db-wal
|
||||
go.sum
|
||||
BIN
favicon.png
Normal file
BIN
favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
21
go.mod
Normal file
21
go.mod
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
module see
|
||||
|
||||
go 1.21
|
||||
|
||||
require modernc.org/sqlite v1.29.5
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.41.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
278
index.html
Normal file
278
index.html
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>see</title>
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
<style>
|
||||
*{
|
||||
margin:0;
|
||||
padding:0;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
background:#0f0f10;
|
||||
color:#e6e6e6;
|
||||
font-family:monospace;
|
||||
margin:0;
|
||||
height:100vh;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:20px;
|
||||
}
|
||||
|
||||
.container{
|
||||
width:100%;
|
||||
max-width:600px;
|
||||
}
|
||||
|
||||
.header{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:14px;
|
||||
margin-bottom:24px;
|
||||
}
|
||||
|
||||
.logo{
|
||||
font-size:28px;
|
||||
color:#fff;
|
||||
}
|
||||
|
||||
form{
|
||||
display:flex;
|
||||
}
|
||||
|
||||
input{
|
||||
flex:1;
|
||||
background:#111;
|
||||
border:1px solid #333;
|
||||
border-right:none;
|
||||
color:#eee;
|
||||
font-family:monospace;
|
||||
font-size:14px;
|
||||
padding:10px;
|
||||
}
|
||||
|
||||
input:focus{
|
||||
outline:none;
|
||||
border-color:#666;
|
||||
}
|
||||
|
||||
button{
|
||||
background:#1c1c1c;
|
||||
border:1px solid #333;
|
||||
color:#fff;
|
||||
font-family:monospace;
|
||||
padding:0 18px;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
button:hover{
|
||||
background:#2a2a2a;
|
||||
}
|
||||
|
||||
button:disabled{
|
||||
opacity:.5;
|
||||
cursor:not-allowed;
|
||||
}
|
||||
|
||||
.output{
|
||||
display:none;
|
||||
margin-top:20px;
|
||||
border:1px solid #333;
|
||||
padding:14px;
|
||||
}
|
||||
|
||||
.output.show{
|
||||
display:block;
|
||||
}
|
||||
|
||||
.label{
|
||||
font-size:11px;
|
||||
color:#666;
|
||||
text-transform:uppercase;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
.output a{
|
||||
display:block;
|
||||
color:#66ff99;
|
||||
text-decoration:none;
|
||||
word-break:break-all;
|
||||
font-size:15px;
|
||||
}
|
||||
|
||||
.output a:hover{
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
.original{
|
||||
margin-top:8px;
|
||||
font-size:12px;
|
||||
color:#777;
|
||||
word-break:break-all;
|
||||
}
|
||||
|
||||
.copy{
|
||||
margin-top:14px;
|
||||
padding:6px 16px;
|
||||
}
|
||||
|
||||
.error{
|
||||
margin-top:12px;
|
||||
font-size:12px;
|
||||
color:#d66;
|
||||
}
|
||||
|
||||
.footer{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
margin-top:24px;
|
||||
padding-top:12px;
|
||||
border-top:1px solid #222;
|
||||
font-size:12px;
|
||||
color:#666;
|
||||
}
|
||||
|
||||
.footer code{
|
||||
font-family:inherit;
|
||||
color:#888;
|
||||
}
|
||||
|
||||
@media(max-width:600px){
|
||||
|
||||
.header{
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
form{
|
||||
flex-direction:column;
|
||||
}
|
||||
|
||||
input{
|
||||
border-right:1px solid #333;
|
||||
border-bottom:none;
|
||||
}
|
||||
|
||||
button{
|
||||
padding:10px;
|
||||
}
|
||||
|
||||
.footer{
|
||||
flex-direction:column;
|
||||
align-items:flex-start;
|
||||
gap:6px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<div class="header">
|
||||
<div class="logo">see</div>
|
||||
</div>
|
||||
|
||||
<form id="f">
|
||||
<input
|
||||
type="url"
|
||||
id="u"
|
||||
placeholder="https://example.com"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<button type="submit" id="b">shorten</button>
|
||||
</form>
|
||||
|
||||
<div id="e" class="error"></div>
|
||||
|
||||
<div id="o" class="output">
|
||||
|
||||
<div class="label">Short URL</div>
|
||||
|
||||
<a href="#" id="l" target="_blank"></a>
|
||||
|
||||
<div class="original" id="r"></div>
|
||||
|
||||
<button class="copy" id="c" type="button">copy</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div><code>POST /api/</code></div>
|
||||
<div>curl -X POST -d "url=LINK" <span id="h"></span>/api/</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
$("h").textContent = location.origin;
|
||||
|
||||
$("f").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
|
||||
$("b").disabled = true;
|
||||
$("e").textContent = "";
|
||||
$("o").classList.remove("show");
|
||||
|
||||
try {
|
||||
|
||||
const response = await fetch("/api/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type":"application/x-www-form-urlencoded"
|
||||
},
|
||||
body: "url=" + encodeURIComponent($("u").value)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const json = JSON.parse(text);
|
||||
throw new Error(json.error || "failed");
|
||||
}
|
||||
|
||||
const shortURL = text.trim();
|
||||
|
||||
$("l").href = shortURL;
|
||||
$("l").textContent = shortURL;
|
||||
|
||||
$("r").textContent = $("u").value;
|
||||
|
||||
$("o").classList.add("show");
|
||||
|
||||
} catch(err) {
|
||||
|
||||
$("e").textContent = err.message;
|
||||
|
||||
} finally {
|
||||
|
||||
$("b").disabled = false;
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
$("c").addEventListener("click", async () => {
|
||||
|
||||
await navigator.clipboard.writeText($("l").href);
|
||||
|
||||
const old = $("c").textContent;
|
||||
$("c").textContent = "copied";
|
||||
|
||||
setTimeout(() => {
|
||||
$("c").textContent = old;
|
||||
}, 1200);
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
343
main.go
Normal file
343
main.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
codeLen = 6
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
limiter = newRateLimiter(10, 10)
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML string
|
||||
|
||||
//go:embed favicon.png
|
||||
var faviconPNG []byte
|
||||
|
||||
type resp struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
visitors map[string]*visitor
|
||||
rate float64
|
||||
burst int
|
||||
}
|
||||
|
||||
func newRateLimiter(perMinute int, burst int) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
rate: float64(perMinute) / 60.0,
|
||||
burst: burst,
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
v, ok := rl.visitors[ip]
|
||||
if !ok {
|
||||
rl.visitors[ip] = &visitor{tokens: float64(rl.burst) - 1, lastSeen: time.Now()}
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
v.tokens = math.Min(float64(rl.burst), v.tokens+now.Sub(v.lastSeen).Seconds()*rl.rate)
|
||||
v.lastSeen = now
|
||||
|
||||
if v.tokens >= 1 {
|
||||
v.tokens--
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) cleanup() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
rl.mu.Lock()
|
||||
for ip, v := range rl.visitors {
|
||||
if time.Since(v.lastSeen) > 3*time.Minute {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.Header.Get("X-Real-Ip")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if i := strings.LastIndex(ip, ":"); i != -1 {
|
||||
ip = ip[:i]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func rateLimit(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !limiter.allow(clientIP(r)) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
json.NewEncoder(w).Encode(resp{Error: "rate limit exceeded"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func hashURL(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func generateCode() string {
|
||||
b := make([]byte, codeLen)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
b[i] = alphabet[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func baseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = h
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
func writeShort(w http.ResponseWriter, r *http.Request, code string) {
|
||||
short := baseURL(r) + "/" + code
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprintln(w, short)
|
||||
}
|
||||
|
||||
func initDB(path string) {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS urls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
url_hash TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("ALTER TABLE urls ADD COLUMN url_hash TEXT"); err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column name") {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Exec("CREATE INDEX IF NOT EXISTS idx_hash ON urls(url_hash)"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("CREATE INDEX IF NOT EXISTS idx_code ON urls(code)"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := db.Query("SELECT id, url FROM urls WHERE url_hash IS NULL")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var u string
|
||||
if err := rows.Scan(&id, &u); err != nil {
|
||||
continue
|
||||
}
|
||||
db.Exec("UPDATE urls SET url_hash = ? WHERE id = ?", hashURL(u), id)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
func normalizeURL(u *url.URL) string {
|
||||
if u.Path == "/" {
|
||||
u.Path = ""
|
||||
}
|
||||
if (u.Scheme == "http" && u.Port() == "80") || (u.Scheme == "https" && u.Port() == "443") {
|
||||
u.Host = u.Hostname()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func shorten(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var raw string
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.Contains(ct, "application/json") {
|
||||
var req struct{ URL string `json:"url"` }
|
||||
if json.NewDecoder(r.Body).Decode(&req) != nil || req.URL == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(resp{Error: "url required"})
|
||||
return
|
||||
}
|
||||
raw = req.URL
|
||||
} else {
|
||||
if err := r.ParseForm(); err == nil {
|
||||
raw = r.FormValue("url")
|
||||
}
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(resp{Error: "url required"})
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(resp{Error: "invalid url"})
|
||||
return
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host == "" || host == "." || (net.ParseIP(host) == nil && !strings.Contains(host, ".")) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(resp{Error: "invalid url"})
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeURL(u)
|
||||
urlHash := hashURL(normalized)
|
||||
|
||||
var existingCode string
|
||||
if err := db.QueryRow("SELECT code FROM urls WHERE url_hash = ? AND url = ?", urlHash, normalized).Scan(&existingCode); err == nil {
|
||||
writeShort(w, r, existingCode)
|
||||
return
|
||||
}
|
||||
|
||||
var code string
|
||||
for i := 0; i < 10; i++ {
|
||||
code = generateCode()
|
||||
if _, err := db.Exec("INSERT INTO urls (code, url, url_hash) VALUES (?, ?, ?)", code, normalized, urlHash); err == nil {
|
||||
break
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
log.Printf("db error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(resp{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
code = ""
|
||||
}
|
||||
if code == "" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(resp{Error: "could not generate code"})
|
||||
return
|
||||
}
|
||||
|
||||
writeShort(w, r, code)
|
||||
}
|
||||
|
||||
func redirect(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if code == "" {
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
var target string
|
||||
if err := db.QueryRow("SELECT url FROM urls WHERE code = ?", code).Scan(&target); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
log.Printf("db error: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
func favicon(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write(faviconPNG)
|
||||
}
|
||||
|
||||
func index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, indexHTML)
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := flag.String("p", "8080", "port")
|
||||
dbPath := flag.String("db", "see.db", "sqlite path")
|
||||
flag.Parse()
|
||||
|
||||
initDB(*dbPath)
|
||||
defer db.Close()
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/":
|
||||
index(w, r)
|
||||
case r.URL.Path == "/favicon.ico" || r.URL.Path == "/favicon.png":
|
||||
favicon(w, r)
|
||||
case r.URL.Path == "/api/":
|
||||
rateLimit(shorten)(w, r)
|
||||
default:
|
||||
redirect(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
log.Printf("see running on :%s", *port)
|
||||
log.Fatal(http.ListenAndServe(":"+*port, nil))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue