From d773a632f66ca4d68f685d437ce5abd750949c78 Mon Sep 17 00:00:00 2001 From: Nixietab <75538775+nixietab@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:48:43 -0300 Subject: [PATCH] real initial commit --- .gitignore | 5 + favicon.png | Bin 0 -> 1427 bytes go.mod | 21 ++++ index.html | 278 ++++++++++++++++++++++++++++++++++++++++++ main.go | 343 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 647 insertions(+) create mode 100644 .gitignore create mode 100644 favicon.png create mode 100644 go.mod create mode 100644 index.html create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3da701 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +see.db-shm +see +see.db +see.db-wal +go.sum diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..864f3f57637f22848dbefd416766d55dc03c60c1 GIT binary patch literal 1427 zcmV;E1#J3>P)#?)ZN`p@$vEW{P}bKd0}CJrlzKNEiNvy!QI_m+TPwK z0J*%pBomY0YR=Ek`Q5_8f}TEoO8)--l$@MQX=!Pyj3FW;Z5eiUc2Z?!CFSPk(yw2? zXlQ7NHa9m(r_+&xg9Ev`x{{ll8#y^S(W6I?$jHcu*V)+_wY9a;{QNxW^?KUa*r2Vg zE%ppR+1c6A+qZA2u&|K5q<~0CNg)-$^z=04<>j$qoCv1~3k##z*jRqTFkEa%)z#J1 z+S*F+N^^5FK`A`Bv$I3+*yQ9Sjg5`52cZ*(Y;SL;hK2?{m*C`0*nv{PE)l9UUEU?BNLzJY{chPmz(4R8&+%p`oEPI5ZO$H3Q&S2G3S!r+udg$@uV26N>2NZIEG;ehlsP#$oEr!mPJ%hS`7k;<=pTAZM^ww7hS zeEA~s^Yev|kB_jmwG|SOFf%g~vfP9$KOyr(6c-nZ41pP!$QWp_zfGO5cz-#addQ~@$qqziLHvGO0Yn$UcKU6<2Q6aB;C7r z?^I#!=G(V#Btt+K7Z+MKd3kx!>({SIhD7B<28oF_Jv%$A^0ATLRNu3+vpErU$l8*U z5=NSpm8A-3H>hFAq3vqv{rmS+Utdp&iHTY^%fVA%iAt51r`qb1^Et};480}agctrt#l2=G4lZ!rm`ouM?Fb$tBE-r>F ze#g83sPEsuU-_=YS<75nTH=lf9(Tb1KXMY@WI$0M>s}o^S}!CdB#_K_8Xg{2MENNz zgBuQJi9(nMNKlMuZ*OmfO==aWoIh-WgM(Qi5=#pJT@y=7I6XQm?gVjhaq1^{8ibH* z=}tEyw9}ZF7>@U!+B + + + + +see + + + + + + +
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
Short URL
+ + + +
+ + + +
+ + + +
+ + + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..193f5ac --- /dev/null +++ b/main.go @@ -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)) +}