This commit is contained in:
Tijl 2025-08-06 17:38:23 +02:00
parent 4f291c93e5
commit 3892d966f3
Signed by: tijl
GPG Key ID: DAE24BFCD722F053
17 changed files with 831 additions and 490 deletions

View File

@ -1,72 +0,0 @@
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
}

View File

@ -2,18 +2,30 @@ package main
import ( import (
"git.tijl.dev/tijl/shortify" "git.tijl.dev/tijl/shortify"
"github.com/gofiber/fiber/v2"
) )
func main() { func main() {
s, err := shortify.NewShortener(shortify.Config{ s, err := shortify.New(shortify.Config{
DataFolder: "./shortify", DataFolder: "./shoritfy",
CacheSize: 10000,
AccessLogSize: 10000,
}) })
if err != nil { if err != nil {
panic(err) panic(err)
} }
go s.ServeSocket("./data/shortify.sock") // listen the admin interface
s.ServeHTTP("0.0.0.0:3001")
unixListener, err := shortify.GetUnixListener("./shortify/admin.sock")
go s.Admin().Listener(unixListener)
// listen the interface for normies
a := fiber.New()
a.Get("/shorten", s.HandlePostURL())
a.Get("/s/:id", s.HandleGetURL())
a.Listen(":3001")
} }

View File

@ -1,218 +0,0 @@
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
}

View File

@ -1,35 +0,0 @@
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
}

41
go.mod
View File

@ -1,3 +1,44 @@
module git.tijl.dev/tijl/shortify module git.tijl.dev/tijl/shortify
go 1.24.3 go 1.24.3
require (
github.com/gofiber/fiber/v2 v2.52.9
github.com/hashicorp/golang-lru v1.0.2
github.com/marcboeker/go-duckdb/v2 v2.3.4
go.etcd.io/bbolt v1.4.2
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.4.0 // indirect
github.com/duckdb/duckdb-go-bindings v0.1.17 // indirect
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12 // indirect
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12 // indirect
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.12 // indirect
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.12 // indirect
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.12 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/marcboeker/go-duckdb/arrowmapping v0.0.10 // indirect
github.com/marcboeker/go-duckdb/mapping v0.0.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
)

100
go.sum Normal file
View File

@ -0,0 +1,100 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.4.0 h1:/RvkGqH517iY8bZKc4FD5/kkdwXJGjxf28JIXbJ/oB0=
github.com/apache/arrow-go/v18 v18.4.0/go.mod h1:Aawvwhj8x2jURIzD9Moy72cF0FyJXOpkYpdmGRHcw14=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/duckdb/duckdb-go-bindings v0.1.17 h1:SjpRwrJ7v0vqnIvLeVFHlhuS72+Lp8xxQ5jIER2LZP4=
github.com/duckdb/duckdb-go-bindings v0.1.17/go.mod h1:pBnfviMzANT/9hi4bg+zW4ykRZZPCXlVuvBWEcZofkc=
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12 h1:8CLBnsq9YDhi2Gmt3sjSUeXxMzyMQAKefjqUy9zVPFk=
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.12/go.mod h1:Ezo7IbAfB8NP7CqPIN8XEHKUg5xdRRQhcPPlCXImXYA=
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12 h1:wjO4I0GhMh2xIpiUgRpzuyOT4KxXLoUS/rjU7UUVvCE=
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.12/go.mod h1:eS7m/mLnPQgVF4za1+xTyorKRBuK0/BA44Oy6DgrGXI=
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.12 h1:HzKQi2C+1jzmwANsPuYH6x9Sfw62SQTjNAEq3OySKFI=
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.12/go.mod h1:1GOuk1PixiESxLaCGFhag+oFi7aP+9W8byymRAvunBk=
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.12 h1:YGSR7AFLw2gJ7IbgLE6DkKYmgKv1LaRSd/ZKF1yh2oE=
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.12/go.mod h1:o7crKMpT2eOIi5/FY6HPqaXcvieeLSqdXXaXbruGX7w=
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.12 h1:2aduW6fnFnT2Q45PlIgHbatsPOxV9WSZ5B2HzFfxaxA=
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.12/go.mod h1:IlOhJdVKUJCAPj3QsDszUo8DVdvp1nBFp4TUJVdw99s=
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/marcboeker/go-duckdb/arrowmapping v0.0.10 h1:G1W+GVnUefR8uy7jHdNO+CRMsmFG5mFPIHVAespfFCA=
github.com/marcboeker/go-duckdb/arrowmapping v0.0.10/go.mod h1:jccUb8TYD0p5TsEEeN4SXuslNJHo23QaKOqKD+U6uFU=
github.com/marcboeker/go-duckdb/mapping v0.0.11 h1:fusN1b1l7Myxafifp596I6dNLNhN5Uv/rw31qAqBwqw=
github.com/marcboeker/go-duckdb/mapping v0.0.11/go.mod h1:aYBjFLgfKO0aJIbDtXPiaL5/avRQISveX/j9tMf9JhU=
github.com/marcboeker/go-duckdb/v2 v2.3.4 h1:o98wrefPbH0IdJRix4pF0+jZiXoFQ+FSR8InMsCUZD0=
github.com/marcboeker/go-duckdb/v2 v2.3.4/go.mod h1:8adNrftF4Ye29XMrpIl5NYNosTVsZu1mz3C82WdVvrk=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -21,13 +21,13 @@ type VisitLog struct {
Time time.Time Time time.Time
} }
func (s *Shortener) LogVisit(log VisitLog) { func (s *Server) LogVisit(log VisitLog) {
s.logChan <- log s.logChan <- log
} }
func (s *Shortener) startLogging() { func (s *Server) startLogging() {
db, err := sql.Open("duckdb", s.DataFolder+"/analytics.db") db, err := sql.Open("duckdb", s.dataFolder+"/analytics.db")
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return return

135
pkg/client/client.go Normal file
View File

@ -0,0 +1,135 @@
package client
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.tijl.dev/tijl/shortify/pkg/generation"
bolt "go.etcd.io/bbolt"
)
type Client struct {
serverURL string
httpClient *http.Client
prefix uint16
gen *generation.Generator
domain string // e.g. https://sho.rt
db *bolt.DB
retryQueue chan shortenJob
stopRetry chan struct{}
}
// NewClient with persistence and retry queue
func NewClient(serverURL, domain string) (*Client, error) {
httpClient, baseURL, err := createHTTPClient(serverURL)
if err != nil {
return nil, err
}
db, err := bolt.Open(dbFileName, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return nil, err
}
cli := &Client{
serverURL: baseURL,
httpClient: httpClient,
domain: domain,
db: db,
retryQueue: make(chan shortenJob, 1000),
stopRetry: make(chan struct{}),
}
// Create buckets if not exist
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(bucketPrefix))
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists([]byte(bucketRetryJobs))
return err
})
if err != nil {
return nil, err
}
// Load or get prefix
var prefix uint16
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucketPrefix))
v := b.Get([]byte("prefix"))
if v == nil {
return nil
}
if len(v) != 2 {
return fmt.Errorf("invalid prefix length in db")
}
prefix = binary.BigEndian.Uint16(v)
return nil
})
if err != nil {
return nil, err
}
// If prefix not found, register new one and save it
if prefix == 0 {
prefix, err = cli.registerPrefix()
if err != nil {
return nil, err
}
err = db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucketPrefix))
buf := make([]byte, 2)
binary.BigEndian.PutUint16(buf, prefix)
return b.Put([]byte("prefix"), buf)
})
if err != nil {
return nil, err
}
}
cli.prefix = prefix
cli.gen = generation.NewGenerator(prefix)
// Load retry jobs from DB into channel
go cli.loadRetryJobs()
// Start retry worker
go cli.retryWorker()
return cli, nil
}
// Shorten creates a short URL and sends it async to the central server
func (c *Client) Shorten(longURL string) string {
shortID := c.gen.NextID()
go func() {
payload := map[string]string{
"id": shortID,
"url": longURL,
}
data, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/shorten", c.serverURL), bytes.NewReader(data))
if err != nil {
log.Println("shorten request build error:", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
log.Println("shorten request failed:", err)
return
}
defer resp.Body.Close()
}()
return fmt.Sprintf("%s/%s", c.domain, shortID)
}

138
pkg/client/store.go Normal file
View File

@ -0,0 +1,138 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
bolt "go.etcd.io/bbolt"
)
const (
bucketPrefix = "prefix"
bucketRetryJobs = "retry_queue"
dbFileName = "shorty_client.db"
)
type shortenJob struct {
ID string `json:"id"`
URL string `json:"url"`
}
func (c *Client) registerPrefix() (uint16, error) {
resp, err := c.httpClient.Post(fmt.Sprintf("%s/register", c.serverURL), "application/json", nil)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var result struct {
Prefix uint16 `json:"prefix"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, err
}
return result.Prefix, nil
}
func (c *Client) loadRetryJobs() {
c.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucketRetryJobs))
b.ForEach(func(k, v []byte) error {
var job shortenJob
err := json.Unmarshal(v, &job)
if err == nil {
select {
case c.retryQueue <- job:
default:
// channel full, drop or log
}
}
return nil
})
return nil
})
}
func (c *Client) retryWorker() {
for {
select {
case job := <-c.retryQueue:
err := c.sendShortenJob(job)
if err != nil {
// Re-enqueue with delay
go func(j shortenJob) {
time.Sleep(2 * time.Second)
c.enqueueJob(j)
}(job)
} else {
c.deleteJobFromDB(job)
}
case <-c.stopRetry:
return
}
}
}
func (c *Client) enqueueJob(job shortenJob) {
// store in DB with key = job.ID
err := c.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucketRetryJobs))
data, err := json.Marshal(job)
if err != nil {
return err
}
return b.Put([]byte(job.ID), data)
})
if err != nil {
log.Println("Failed to store job in db:", err)
}
select {
case c.retryQueue <- job:
default:
log.Println("Retry queue full, dropping job:", job.ID)
}
}
func (c *Client) deleteJobFromDB(job shortenJob) {
err := c.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucketRetryJobs))
return b.Delete([]byte(job.ID))
})
if err != nil {
log.Println("Failed to delete job from db:", err)
}
}
func (c *Client) sendShortenJob(job shortenJob) error {
payload := map[string]string{
"id": job.ID,
"url": job.URL,
}
data, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/shorten", c.serverURL), bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("server returned status %d", resp.StatusCode)
}
return nil
}
func (c *Client) Close() error {
close(c.stopRetry)
return c.db.Close()
}

42
pkg/client/transport.go Normal file
View File

@ -0,0 +1,42 @@
package client
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// createHTTPClient creates an http.Client with support for HTTP or unix socket transport
func createHTTPClient(serverURL string) (*http.Client, string, error) {
if strings.HasPrefix(serverURL, "unix://") {
// Extract socket path and strip the scheme
socketPath := strings.TrimPrefix(serverURL, "unix://")
// We'll fake a URL host for use in requests
baseURL := "http://unix"
transport := &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
}
return &http.Client{
Transport: transport,
Timeout: 2 * time.Second,
}, baseURL, nil
}
// Default HTTP transport
parsed, err := url.Parse(serverURL)
if err != nil {
return nil, "", err
}
return &http.Client{
Timeout: 2 * time.Second,
}, parsed.String(), nil
}

62
pkg/generation/base62.go Normal file
View File

@ -0,0 +1,62 @@
package generation
import (
"errors"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var (
base = len(alphabet)
rev [256]int
)
func init() {
for i := range rev {
rev[i] = -1
}
for i, c := range alphabet {
rev[c] = i
}
}
// EncodeBase62 encodes a byte slice into a base62 string
func EncodeBase62(b []byte) string {
var n uint64
for i := 0; i < len(b); i++ {
n = n<<8 + uint64(b[i])
}
if n == 0 {
return string(alphabet[0])
}
var s []byte
for n > 0 {
r := n % uint64(base)
s = append([]byte{alphabet[r]}, s...)
n = n / uint64(base)
}
return string(s)
}
// DecodeBase62 decodes a base62 string into a byte slice
func DecodeBase62(s string) ([]byte, error) {
var n uint64
for i := 0; i < len(s); i++ {
c := s[i]
val := rev[c]
if val == -1 {
return nil, errors.New("invalid base62 character")
}
n = n*uint64(base) + uint64(val)
}
// Recover byte slice from integer
b := make([]byte, rawIDLength)
for i := rawIDLength - 1; i >= 0; i-- {
b[i] = byte(n & 0xff)
n >>= 8
}
return b, nil
}

View File

@ -0,0 +1,78 @@
package generation
import (
cryptoRand "crypto/rand"
"encoding/binary"
"sync"
)
const (
idSize = 4 // 4 bytes for random part
prefixSize = 2 // 2 bytes for client prefix
rawIDLength = prefixSize + idSize // total 6 bytes
base62Len = 8 // 6 bytes encoded in base62 ~ 8 chars
poolSize = 10000
)
type Generator struct {
prefix [2]byte
idPool chan string
mu sync.Mutex
started bool
}
// NewGenerator initializes the generator with a 2-byte prefix
func NewGenerator(prefix uint16) *Generator {
var b [2]byte
binary.BigEndian.PutUint16(b[:], prefix)
g := &Generator{
prefix: b,
idPool: make(chan string, poolSize),
}
go g.fillPool()
return g
}
// fillPool pre-generates short IDs into the pool
func (g *Generator) fillPool() {
for {
for i := 0; i < poolSize/10; i++ {
id := g.generateRawID()
g.idPool <- EncodeBase62(id)
}
}
}
// generateRawID creates 6 bytes: 2-byte prefix + 4-byte random
func (g *Generator) generateRawID() []byte {
random := make([]byte, idSize)
_, err := cryptoRand.Read(random)
if err != nil {
panic("failed to read random bytes: " + err.Error())
}
raw := make([]byte, rawIDLength)
copy(raw[0:2], g.prefix[:])
copy(raw[2:], random)
return raw
}
// NextID returns the next available short ID from the pool
func (g *Generator) NextID() string {
return <-g.idPool
}
// DecodeID (optional) for analytics/debugging
func DecodeID(shortID string) (prefix uint16, randomPart []byte, err error) {
raw, err := DecodeBase62(shortID)
if err != nil {
return 0, nil, err
}
if len(raw) != rawIDLength {
return 0, nil, err
}
prefix = binary.BigEndian.Uint16(raw[0:2])
randomPart = raw[2:]
return
}

80
prefixes.go Normal file
View File

@ -0,0 +1,80 @@
package shortify
import (
"encoding/binary"
"errors"
bolt "go.etcd.io/bbolt"
)
var (
ErrNoPrefixes = errors.New("no more prefixes available")
)
type PrefixManager struct {
db *bolt.DB
bucket []byte
maxID uint16
allocated map[uint16]struct{}
}
func NewPrefixManager(db *bolt.DB) (*PrefixManager, error) {
pm := &PrefixManager{
db: db,
bucket: []byte("prefixes"),
allocated: make(map[uint16]struct{}),
maxID: 0, // highest allocated prefix so far
}
// create bucket if not exists and load allocated prefixes
err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(pm.bucket)
return err
})
if err != nil {
return nil, err
}
// load existing prefixes
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(pm.bucket)
return b.ForEach(func(k, v []byte) error {
if len(k) != 2 {
return nil
}
id := binary.BigEndian.Uint16(k)
pm.allocated[id] = struct{}{}
if id > pm.maxID {
pm.maxID = id
}
return nil
})
})
if err != nil {
return nil, err
}
return pm, nil
}
// AllocateNewPrefix assigns next prefix > 0
func (pm *PrefixManager) AllocateNewPrefix() (uint16, error) {
pm.maxID++
if pm.maxID == 0 {
// skip zero because reserved for server
pm.maxID++
}
if pm.maxID == 0xFFFF {
return 0, ErrNoPrefixes
}
// persist allocation
err := pm.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(pm.bucket)
key := make([]byte, 2)
binary.BigEndian.PutUint16(key, pm.maxID)
return b.Put(key, []byte{1})
})
if err != nil {
return 0, err
}
pm.allocated[pm.maxID] = struct{}{}
return pm.maxID, nil
}

52
serve.go Normal file
View File

@ -0,0 +1,52 @@
package shortify
import (
"encoding/binary"
"net"
"os"
"github.com/gofiber/fiber/v2"
)
func (s *Server) Admin() *fiber.App {
a := fiber.New()
a.Get("/register", func(c *fiber.Ctx) error {
prefix, err := s.prefixManager.AllocateNewPrefix()
if err != nil {
return err
}
var response []byte
binary.LittleEndian.PutUint16(response, prefix)
return c.Send(response)
})
a.Post("/shorten", func(c *fiber.Ctx) error {
shortUrl := c.Query("s")
longUrl := string(c.Body())
return s.storage.Put(shortUrl, longUrl)
})
return a
}
// util
func GetUnixListener(path string) (net.Listener, error) {
// Remove old socket if exists
socketPath := path
_ = os.Remove(socketPath)
// Create a Unix socket listener
ln, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
// Optionally set permissions so other processes can connect
_ = os.Chmod(socketPath, 0666)
return ln, nil
}

View File

@ -2,63 +2,41 @@ package shortify
import ( import (
"errors" "errors"
"log"
"net"
"os"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// wow var ErrNotFound = errors.New("short URL not found")
func (s *Shortener) ServeSocket(path string) {
// Remove old socket if exists // GetURL first checks cache, then DB, and updates cache on hit
socketPath := path func (s *Server) GetURL(id string) (string, error) {
_ = os.Remove(socketPath) s.cacheLock.RLock()
if val, ok := s.cache.Get(id); ok {
s.cacheLock.RUnlock()
return val.(string), nil
}
s.cacheLock.RUnlock()
// Create a Unix socket listener url, err := s.storage.Get(id)
ln, err := net.Listen("unix", socketPath)
if err != nil { if err != nil {
log.Fatalf("Failed to listen on unix socket: %v", err) return "", err
} }
// Optionally set permissions so other processes can connect s.cacheLock.Lock()
_ = os.Chmod(socketPath, 0666) s.cache.Add(id, url)
s.cacheLock.Unlock()
app := fiber.New() return url, nil
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 { /* Simple routes to use for fiber below! */
shortID, err := s.idPool.PopID()
if err != nil {
return ""
}
err = s.put(shortID, longUrl) func (s *Server) HandleGetURL() func(*fiber.Ctx) error {
if err != nil {
return ""
}
return shortID
}
func (s *Shortener) HandleGETShortURL() func(*fiber.Ctx) error {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
shortID := c.Params("id") shortID := c.Params("id")
url, err := s.get(shortID) url, err := s.GetURL(shortID)
if err != nil { if err != nil {
return err return err
} }
@ -81,42 +59,17 @@ func (s *Shortener) HandleGETShortURL() func(*fiber.Ctx) error {
} }
} }
func (s *Shortener) HandlePOSTShortURL() func(*fiber.Ctx) error { func (s *Server) HandlePostURL() func(*fiber.Ctx) error {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
longUrl := string(c.Body())
if longUrl == "" { targetUrl := string(c.Body())
return errors.New("whut")
}
shortID, err := s.idPool.PopID() newId := s.serverGen.NextID()
if err != nil {
if err := s.storage.Put(newId, targetUrl); err != nil {
return err return err
} }
err = s.put(shortID, longUrl) return c.SendString(newId)
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)
} }
} }

View File

@ -3,56 +3,55 @@ package shortify
import ( import (
"sync" "sync"
"git.tijl.dev/tijl/shortify/pkg/generation"
"github.com/hashicorp/golang-lru" "github.com/hashicorp/golang-lru"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
type Shortener struct { type Server struct {
DataFolder string storage *Storage
prefixManager *PrefixManager
serverGen *generation.Generator
cache *lru.Cache
cacheLock sync.RWMutex
db *bolt.DB dataFolder string
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
logChan chan VisitLog
} }
type Config struct { type Config struct {
DataFolder string DataFolder string
CacheSize int
AccessLogSize int
} }
func NewShortener(cfg Config) (*Shortener, error) { func New(cfg Config) (*Server, error) {
db, err := bolt.Open(cfg.DataFolder+"/database.db", 0600, nil) db, err := bolt.Open(cfg.DataFolder+"/database.db", 0600, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
urlCache, _ := lru.New(cfg.CacheSize) prefixManager, err := NewPrefixManager(db)
accessCache, _ := lru.New(cfg.AccessLogSize)
idPool, err := NewIDPool(db, 8, 10000, 2000)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &Shortener{ storage, err := NewStorage(db)
DataFolder: cfg.DataFolder, if err != nil {
db: db, return nil, err
cache: urlCache, }
accessCache: accessCache,
logChan: make(chan VisitLog, 1000), cache, _ := lru.New(10000)
writeChan: make(chan [2]string, 1000),
idPool: idPool, s := &Server{
dataFolder: cfg.DataFolder,
storage: storage,
cache: cache,
serverGen: generation.NewGenerator(0),
prefixManager: prefixManager,
logChan: make(chan VisitLog, 1000),
} }
go s.startLogging() go s.startLogging()
go s.asyncDBWriter()
return s, nil return s, nil
} }

View File

@ -1,71 +1,45 @@
package shortify package shortify
import ( import (
"errors" bolt "go.etcd.io/bbolt"
"log"
"go.etcd.io/bbolt"
) )
var bucketName = []byte("shorturls") type Storage struct {
db *bolt.DB
bucket []byte
}
func (s *Shortener) get(shortID string) (string, error) { func NewStorage(db *bolt.DB) (*Storage, error) {
if val, ok := s.memStore.Load(shortID); ok { bucket := []byte("shorturls")
s.cache.Add(shortID, val.(string)) err := db.Update(func(tx *bolt.Tx) error {
return val.(string), nil _, err := tx.CreateBucketIfNotExists(bucket)
} return err
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")
}) })
if err != nil {
return longURL, err return nil, 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 &Storage{db: db, bucket: bucket}, nil
return nil
} }
func (s *Shortener) asyncDBWriter() { func (s *Storage) Put(id, url string) error {
for pair := range s.writeChan { return s.db.Update(func(tx *bolt.Tx) error {
shortID := pair[0] b := tx.Bucket(s.bucket)
longURL := pair[1] return b.Put([]byte(id), []byte(url))
})
}
err := s.db.Update(func(tx *bbolt.Tx) error { func (s *Storage) Get(id string) (string, error) {
b, err := tx.CreateBucketIfNotExists(bucketName) var url []byte
if err != nil { err := s.db.View(func(tx *bolt.Tx) error {
return err b := tx.Bucket(s.bucket)
} url = b.Get([]byte(id))
return b.Put([]byte(shortID), []byte(longURL)) return nil
}) })
if err != nil { if err != nil {
log.Printf("DB write failed for %s: %v", shortID, err) return "", err
}
} }
if url == nil {
return "", ErrNotFound
}
return string(url), nil
} }