first commit
This commit is contained in:
commit
4f291c93e5
72
client.go
Normal file
72
client.go
Normal file
@ -0,0 +1,72 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
UseUnixSocket bool // true = unix socket, false = http
|
||||
SocketPath string // e.g. /tmp/shorty.sock
|
||||
HTTPAddress string // e.g. http://localhost:8080
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
transport := &http.Transport{}
|
||||
|
||||
if cfg.UseUnixSocket {
|
||||
dialer := func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", cfg.SocketPath)
|
||||
}
|
||||
transport.DialContext = dialer
|
||||
cfg.HTTPAddress = "http://unix" // dummy for request building
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
return &Client{
|
||||
httpClient: client,
|
||||
baseURL: cfg.HTTPAddress,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Shorten(url string) (string, error) {
|
||||
body := []byte(url)
|
||||
|
||||
req, err := http.NewRequest("POST", c.baseURL+"/s", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", errors.New("shorten failed: " + string(b))
|
||||
}
|
||||
|
||||
result, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
}
|
19
examples/main.go
Normal file
19
examples/main.go
Normal file
@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.tijl.dev/tijl/shortify"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s, err := shortify.NewShortener(shortify.Config{
|
||||
DataFolder: "./shortify",
|
||||
CacheSize: 10000,
|
||||
AccessLogSize: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
go s.ServeSocket("./data/shortify.sock")
|
||||
s.ServeHTTP("0.0.0.0:3001")
|
||||
}
|
218
generation.go
Normal file
218
generation.go
Normal file
@ -0,0 +1,218 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type IDPool struct {
|
||||
db *bbolt.DB
|
||||
mu sync.Mutex
|
||||
idLen int
|
||||
poolCap int
|
||||
regenThresh int
|
||||
inMemoryPool []string
|
||||
cond *sync.Cond
|
||||
stopCh chan struct{}
|
||||
usedChan chan string
|
||||
}
|
||||
|
||||
func NewIDPool(db *bbolt.DB, idLen int, poolCap int, regenThresh int) (*IDPool, error) {
|
||||
p := &IDPool{
|
||||
db: db,
|
||||
idLen: idLen,
|
||||
poolCap: poolCap,
|
||||
regenThresh: regenThresh,
|
||||
stopCh: make(chan struct{}),
|
||||
usedChan: make(chan string, 1000),
|
||||
}
|
||||
p.cond = sync.NewCond(&p.mu)
|
||||
|
||||
err := db.Update(func(tx *bbolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists(idpoolBucket)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.loadFromDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(p.inMemoryPool) == 0 {
|
||||
// idpool empty at startup, generating initial batch...
|
||||
if err := p.GenerateBatch(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.loadFromDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
go p.backgroundGenerator()
|
||||
go p.flushUsedIDs()
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *IDPool) loadFromDB() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
var ids []string
|
||||
|
||||
err := p.db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(idpoolBucket)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c := b.Cursor()
|
||||
for k, _ := c.First(); k != nil; k, _ = c.Next() {
|
||||
ids = append(ids, string(k))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.inMemoryPool = ids
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *IDPool) GenerateBatch() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
return p.db.Update(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(idpoolBucket)
|
||||
count := b.Stats().KeyN
|
||||
for count < p.poolCap {
|
||||
id, err := generateID(p.idLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.Get([]byte(id)) != nil {
|
||||
continue
|
||||
}
|
||||
if err := b.Put([]byte(id), []byte{}); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// PopID returns an ID from memory and queues it for async DB removal
|
||||
func (p *IDPool) PopID() (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if len(p.inMemoryPool) == 0 {
|
||||
return "", errors.New("id pool empty")
|
||||
}
|
||||
|
||||
// Fast O(1) pop
|
||||
id := p.inMemoryPool[0]
|
||||
p.inMemoryPool = p.inMemoryPool[1:]
|
||||
|
||||
// Queue for async delete
|
||||
select {
|
||||
case p.usedChan <- id:
|
||||
default:
|
||||
// If the channel is full, we drop the delete. Risky only if shutdown happens
|
||||
log.Println("Warning: used ID queue full; delete may be delayed")
|
||||
}
|
||||
|
||||
// Signal for batch regen if low
|
||||
if len(p.inMemoryPool) < p.regenThresh {
|
||||
p.cond.Signal()
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (p *IDPool) backgroundGenerator() {
|
||||
for {
|
||||
p.mu.Lock()
|
||||
for len(p.inMemoryPool) >= p.regenThresh {
|
||||
p.cond.Wait()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// generating batch
|
||||
err := p.GenerateBatch()
|
||||
if err != nil {
|
||||
// error generating batch:(
|
||||
time.Sleep(time.Second * 5)
|
||||
continue
|
||||
}
|
||||
if err := p.loadFromDB(); err != nil {
|
||||
// error laoding from db:(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IDPool) flushUsedIDs() {
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case id := <-p.usedChan:
|
||||
err := p.db.Update(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(idpoolBucket)
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete used ID %s: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IDPool) Stop() {
|
||||
close(p.stopCh)
|
||||
// Drain and flush remaining used IDs
|
||||
for {
|
||||
select {
|
||||
case id := <-p.usedChan:
|
||||
p.db.Update(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(idpoolBucket)
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var idpoolBucket = []byte("idpool")
|
||||
var base62 = []rune("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
func generateID(n int) (string, error) {
|
||||
id := make([]rune, n)
|
||||
for i := range id {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(base62))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id[i] = base62[num.Int64()]
|
||||
}
|
||||
return string(id), nil
|
||||
}
|
35
global.go
Normal file
35
global.go
Normal file
@ -0,0 +1,35 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Global instance
|
||||
var (
|
||||
Global *Shortener
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func Init(cfg Config) error {
|
||||
var err error
|
||||
once.Do(func() {
|
||||
Global, err = NewShortener(cfg)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Global instance (duc)
|
||||
var (
|
||||
GlobalIDPool *IDPool
|
||||
oncePopper sync.Once
|
||||
)
|
||||
|
||||
func InitIDPool(db *bbolt.DB, idLen, poolCap, regenThresh int) error {
|
||||
var err error
|
||||
oncePopper.Do(func() {
|
||||
GlobalIDPool, err = NewIDPool(db, idLen, poolCap, regenThresh)
|
||||
})
|
||||
return err
|
||||
}
|
72
logger.go
Normal file
72
logger.go
Normal file
@ -0,0 +1,72 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/marcboeker/go-duckdb/v2"
|
||||
)
|
||||
|
||||
type VisitLog struct {
|
||||
ShortID string
|
||||
LongURL string
|
||||
|
||||
IP string
|
||||
|
||||
UserAgent string
|
||||
Referer string
|
||||
Language string
|
||||
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func (s *Shortener) LogVisit(log VisitLog) {
|
||||
s.logChan <- log
|
||||
}
|
||||
|
||||
func (s *Shortener) startLogging() {
|
||||
|
||||
db, err := sql.Open("duckdb", s.DataFolder+"/analytics.db")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE SEQUENCE IF NOT EXISTS seq_visitid START 1;
|
||||
CREATE TABLE IF NOT EXISTS visits (
|
||||
id INTEGER PRIMARY KEY,
|
||||
shortid VARCHAR,
|
||||
longurl VARCHAR,
|
||||
ip VARCHAR,
|
||||
useragent VARCHAR,
|
||||
referer VARCHAR,
|
||||
language VARCHAR,
|
||||
time TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
stmt, err := db.Prepare(`
|
||||
INSERT INTO visits (id, shortid, longurl, ip, useragent, referer, language, time)
|
||||
VALUES (nextval('seq_visitid'), ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for log := range s.logChan {
|
||||
_, err = stmt.Exec(log.ShortID, log.LongURL, log.IP, log.UserAgent, log.Referer, log.Language, log.Time)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
122
server.go
Normal file
122
server.go
Normal file
@ -0,0 +1,122 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// wow
|
||||
func (s *Shortener) ServeSocket(path string) {
|
||||
|
||||
// Remove old socket if exists
|
||||
socketPath := path
|
||||
_ = os.Remove(socketPath)
|
||||
|
||||
// Create a Unix socket listener
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen on unix socket: %v", err)
|
||||
}
|
||||
|
||||
// Optionally set permissions so other processes can connect
|
||||
_ = os.Chmod(socketPath, 0666)
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Post("/s", s.HandlePOSTShortURLDirect())
|
||||
|
||||
log.Fatal(app.Listener(ln))
|
||||
}
|
||||
func (s *Shortener) ServeHTTP(addr string) {
|
||||
app := fiber.New()
|
||||
|
||||
app.Get("/s/:id", s.HandleGETShortURL())
|
||||
|
||||
log.Fatal(app.Listen(addr))
|
||||
}
|
||||
|
||||
func (s *Shortener) NewShortURL(longUrl string) string {
|
||||
shortID, err := s.idPool.PopID()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
err = s.put(shortID, longUrl)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return shortID
|
||||
}
|
||||
|
||||
func (s *Shortener) HandleGETShortURL() func(*fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
shortID := c.Params("id")
|
||||
|
||||
url, err := s.get(shortID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.LogVisit(VisitLog{
|
||||
ShortID: shortID,
|
||||
LongURL: url,
|
||||
|
||||
IP: c.IP(),
|
||||
|
||||
UserAgent: string(c.Context().UserAgent()),
|
||||
Language: c.GetReqHeaders()["Accept-Language"][0],
|
||||
Referer: string(c.Request().Header.Referer()),
|
||||
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
c.Set("Referrer-Policy", "no-referrer")
|
||||
return c.Redirect(url, fiber.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shortener) HandlePOSTShortURL() func(*fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
longUrl := string(c.Body())
|
||||
|
||||
if longUrl == "" {
|
||||
return errors.New("whut")
|
||||
}
|
||||
|
||||
shortID, err := s.idPool.PopID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.put(shortID, longUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.SendString(shortID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shortener) HandlePOSTShortURLDirect() func(*fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
longUrl := string(c.Body())
|
||||
shortID := c.Query("s")
|
||||
|
||||
if longUrl == "" || shortID == "" {
|
||||
return errors.New("no comment")
|
||||
}
|
||||
|
||||
err := s.put(shortID, longUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.SendString(shortID)
|
||||
}
|
||||
}
|
58
shortify.go
Normal file
58
shortify.go
Normal file
@ -0,0 +1,58 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/golang-lru"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type Shortener struct {
|
||||
DataFolder string
|
||||
|
||||
db *bolt.DB
|
||||
cache *lru.Cache
|
||||
accessCache *lru.Cache
|
||||
logChan chan VisitLog
|
||||
idPool *IDPool
|
||||
|
||||
writeChan chan [2]string // queue of writes: [shortID, longURL]
|
||||
memStore sync.Map // thread-safe built-in
|
||||
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DataFolder string
|
||||
CacheSize int
|
||||
AccessLogSize int
|
||||
}
|
||||
|
||||
func NewShortener(cfg Config) (*Shortener, error) {
|
||||
db, err := bolt.Open(cfg.DataFolder+"/database.db", 0600, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urlCache, _ := lru.New(cfg.CacheSize)
|
||||
accessCache, _ := lru.New(cfg.AccessLogSize)
|
||||
|
||||
idPool, err := NewIDPool(db, 8, 10000, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Shortener{
|
||||
DataFolder: cfg.DataFolder,
|
||||
db: db,
|
||||
cache: urlCache,
|
||||
accessCache: accessCache,
|
||||
logChan: make(chan VisitLog, 1000),
|
||||
writeChan: make(chan [2]string, 1000),
|
||||
idPool: idPool,
|
||||
}
|
||||
|
||||
go s.startLogging()
|
||||
go s.asyncDBWriter()
|
||||
|
||||
return s, nil
|
||||
}
|
71
storage.go
Normal file
71
storage.go
Normal file
@ -0,0 +1,71 @@
|
||||
package shortify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var bucketName = []byte("shorturls")
|
||||
|
||||
func (s *Shortener) get(shortID string) (string, error) {
|
||||
if val, ok := s.memStore.Load(shortID); ok {
|
||||
s.cache.Add(shortID, val.(string))
|
||||
return val.(string), nil
|
||||
}
|
||||
|
||||
if val, ok := s.cache.Get(shortID); ok {
|
||||
return val.(string), nil
|
||||
}
|
||||
|
||||
var longURL string
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(bucketName)
|
||||
if b == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
v := b.Get([]byte(shortID))
|
||||
if v != nil {
|
||||
longURL = string(v)
|
||||
s.cache.Add(shortID, longURL)
|
||||
return nil
|
||||
}
|
||||
return errors.New("not found")
|
||||
})
|
||||
|
||||
return longURL, err
|
||||
}
|
||||
|
||||
func (s *Shortener) put(shortID, longURL string) error {
|
||||
|
||||
s.memStore.Store(shortID, longURL)
|
||||
s.cache.Add(shortID, longURL)
|
||||
|
||||
// Queue write to DB (non-blocking)
|
||||
select {
|
||||
case s.writeChan <- [2]string{shortID, longURL}:
|
||||
default:
|
||||
log.Println("Warning: write queue full, short URL write may be dropped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Shortener) asyncDBWriter() {
|
||||
for pair := range s.writeChan {
|
||||
shortID := pair[0]
|
||||
longURL := pair[1]
|
||||
|
||||
err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists(bucketName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(shortID), []byte(longURL))
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("DB write failed for %s: %v", shortID, err)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user