feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
const sessionTTL = 30 * 24 * time.Hour
// AuthService coordinates the OAuth/OIDC login dance and sessions. It supports
// any number of identity providers (Google, generic OIDC) keyed by name.
// providers may be nil on construction and injected later via SetProviders.
type AuthService struct {
mu sync.RWMutex
providers map[string]port.IdentityProvider
users port.UserRepository
sessions port.SessionManager
}
func NewAuthService(providers []port.IdentityProvider, users port.UserRepository, sessions port.SessionManager) *AuthService {
s := &AuthService{users: users, sessions: sessions}
s.setProviders(providers)
return s
}
// SetProviders replaces the provider set; safe to call from a background goroutine.
func (s *AuthService) SetProviders(providers []port.IdentityProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.setProviders(providers)
}
func (s *AuthService) setProviders(providers []port.IdentityProvider) {
m := make(map[string]port.IdentityProvider, len(providers))
for _, p := range providers {
if p != nil {
m[p.Name()] = p
}
}
s.providers = m
}
// Providers lists configured provider names (e.g. "google", "oidc").
func (s *AuthService) Providers() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.providers))
for name := range s.providers {
out = append(out, name)
}
return out
}
// AuthURL returns the provider's authorization URL for the given state.
func (s *AuthService) AuthURL(provider, state string) (string, error) {
s.mu.RLock()
p, ok := s.providers[provider]
s.mu.RUnlock()
if !ok {
return "", domain.ErrNotFound
}
return p.AuthURL(state), nil
}
// Complete exchanges an auth code, upserts the user and issues a session token.
func (s *AuthService) Complete(ctx context.Context, provider, code string) (*domain.User, string, error) {
s.mu.RLock()
p, ok := s.providers[provider]
s.mu.RUnlock()
if !ok {
return nil, "", domain.ErrNotFound
}
id, err := p.Exchange(ctx, code)
if err != nil {
return nil, "", err
}
user, err := s.users.Upsert(ctx, &domain.User{
Email: id.Email,
Name: displayName(id),
Provider: provider,
Subject: id.Subject,
})
if err != nil {
return nil, "", err
}
token, err := s.sessions.Issue(user.ID, sessionTTL)
if err != nil {
return nil, "", err
}
return user, token, nil
}
// Me resolves a session token back to a user.
func (s *AuthService) Me(ctx context.Context, token string) (*domain.User, error) {
userID, err := s.sessions.Verify(token)
if err != nil {
return nil, domain.ErrUnauthorized
}
return s.users.GetByID(ctx, userID)
}
func displayName(id *port.Identity) string {
if id.Name != "" {
return id.Name
}
if id.Email != "" {
return id.Email
}
return "there"
}
// NoopRecorder discards clicks; handy for tests and the api command (which
// doesn't serve redirects).
type NoopRecorder struct{}
func (NoopRecorder) Record(string) {}
+95
View File
@@ -0,0 +1,95 @@
package service
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/port"
)
// AsyncClickRecorder coalesces clicks in memory and flushes them to the
// repository in batches. This keeps the redirect path free of synchronous DB
// writes and collapses bursts (e.g. 1000 clicks/sec on one code) into a single
// periodic UPDATE.
type AsyncClickRecorder struct {
repo port.LinkRepository
interval time.Duration
mu sync.Mutex
pending map[string]int64
stop chan struct{}
done chan struct{}
}
func NewAsyncClickRecorder(repo port.LinkRepository, interval time.Duration) *AsyncClickRecorder {
if interval <= 0 {
interval = 10 * time.Second
}
return &AsyncClickRecorder{
repo: repo,
interval: interval,
pending: make(map[string]int64),
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Record is non-blocking: it just bumps an in-memory counter.
func (r *AsyncClickRecorder) Record(code string) {
r.mu.Lock()
r.pending[code]++
r.mu.Unlock()
}
// Start runs the flush loop until ctx is cancelled or Stop is called.
func (r *AsyncClickRecorder) Start(ctx context.Context) {
go func() {
defer close(r.done)
t := time.NewTicker(r.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
r.flush(context.Background())
return
case <-r.stop:
r.flush(context.Background())
return
case <-t.C:
r.flush(ctx)
}
}
}()
}
// Stop flushes and halts the loop.
func (r *AsyncClickRecorder) Stop() {
select {
case <-r.stop:
default:
close(r.stop)
}
<-r.done
}
func (r *AsyncClickRecorder) flush(ctx context.Context) {
r.mu.Lock()
batch := r.pending
r.pending = make(map[string]int64)
r.mu.Unlock()
if len(batch) == 0 {
return
}
day := time.Now().UTC().Truncate(24 * time.Hour)
for code, n := range batch {
if err := r.repo.RecordClicks(ctx, code, day, n); err != nil {
// Re-queue on failure so clicks aren't lost.
r.mu.Lock()
r.pending[code] += n
r.mu.Unlock()
}
}
}
+255
View File
@@ -0,0 +1,255 @@
package service
import (
"context"
"errors"
"strings"
"time"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
// cacheTTL is how long a resolution stays hot in the cache.
const cacheTTL = 24 * time.Hour
// CreateInput is the use-case input for minting a link.
type CreateInput struct {
LongURL string
Mode domain.Mode
CustomAlias string
Pin string
}
// LinkService is the application core for owning links.
type LinkService struct {
repo port.LinkRepository
cache port.LinkCache
hasher port.PasswordHasher
now func() time.Time
}
func NewLinkService(repo port.LinkRepository, cache port.LinkCache, hasher port.PasswordHasher) *LinkService {
return &LinkService{repo: repo, cache: cache, hasher: hasher, now: time.Now}
}
// Create resolves a code by mode, persists the link and warms the cache.
func (s *LinkService) Create(ctx context.Context, in CreateInput, ownerID string) (*domain.Link, error) {
longURL := NormalizeURL(in.LongURL)
if !ValidURL(longURL) {
return nil, domain.ErrInvalidURL
}
code, err := s.resolveCode(ctx, in, ownerID)
if err != nil {
return nil, err
}
link := &domain.Link{
ID: newID(),
Code: code,
LongURL: longURL,
Mode: in.Mode,
OwnerID: ownerID,
CreatedAt: s.now().UTC(),
Last7Days: emptyWeek(s.now()),
}
// PIN is an authenticated-only feature.
if in.Pin != "" && ownerID != "" {
if !ValidPin(in.Pin) {
return nil, domain.ErrInvalidPin
}
hash, err := s.hasher.Hash(in.Pin)
if err != nil {
return nil, err
}
link.HasPin = true
link.PinHash = hash
}
if err := s.repo.Create(ctx, link); err != nil {
return nil, err
}
s.warm(ctx, link)
return link, nil
}
func (s *LinkService) resolveCode(ctx context.Context, in CreateInput, ownerID string) (string, error) {
switch in.Mode {
case domain.ModeCustom:
if ownerID == "" {
return "", domain.ErrUnauthorized
}
if err := ValidateAlias(in.CustomAlias); err != nil {
return "", err
}
exists, err := s.repo.ExistsCode(ctx, in.CustomAlias)
if err != nil {
return "", err
}
if exists {
return "", domain.ErrCodeTaken
}
return in.CustomAlias, nil
case domain.ModeMemorable:
for i := 0; i < 6; i++ {
code := MemorableSlug()
exists, err := s.repo.ExistsCode(ctx, code)
if err != nil {
return "", err
}
if !exists {
return code, nil
}
}
return "", domain.ErrCodeTaken
default: // ModeRandom
seq, err := s.repo.NextSequence(ctx)
if err != nil {
return "", err
}
return CodeFromSequence(seq), nil
}
}
// Page is one page of a link listing.
type Page struct {
Items []domain.Link
Total int
Page int
PageSize int
}
const (
defaultPageSize = 10
maxPageSize = 100
)
// List returns one page of the caller's links matching query (server-side
// pagination + search). PIN hashes are stripped by the HTTP layer.
func (s *LinkService) List(ctx context.Context, ownerID, query string, page, pageSize int) (Page, error) {
if ownerID == "" {
return Page{}, domain.ErrUnauthorized
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
if page <= 0 {
page = 1
}
items, total, err := s.repo.ListByOwner(ctx, ownerID, strings.TrimSpace(query), pageSize, (page-1)*pageSize)
if err != nil {
return Page{}, err
}
return Page{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// Stats returns the caller's aggregate dashboard tiles.
func (s *LinkService) Stats(ctx context.Context, ownerID string) (domain.OwnerStats, error) {
if ownerID == "" {
return domain.OwnerStats{}, domain.ErrUnauthorized
}
return s.repo.StatsByOwner(ctx, ownerID)
}
// UpdateDestination changes where an owned link points and invalidates cache.
func (s *LinkService) UpdateDestination(ctx context.Context, id, ownerID, rawURL string) (*domain.Link, error) {
link, err := s.ownedLink(ctx, id, ownerID)
if err != nil {
return nil, err
}
longURL := NormalizeURL(rawURL)
if !ValidURL(longURL) {
return nil, domain.ErrInvalidURL
}
link.LongURL = longURL
if err := s.repo.Update(ctx, link); err != nil {
return nil, err
}
s.warm(ctx, link)
return link, nil
}
// SetPin sets or replaces a PIN; pass empty to remove it.
func (s *LinkService) SetPin(ctx context.Context, id, ownerID, pin string) (*domain.Link, error) {
link, err := s.ownedLink(ctx, id, ownerID)
if err != nil {
return nil, err
}
if pin == "" {
link.HasPin = false
link.PinHash = ""
} else {
if !ValidPin(pin) {
return nil, domain.ErrInvalidPin
}
hash, err := s.hasher.Hash(pin)
if err != nil {
return nil, err
}
link.HasPin = true
link.PinHash = hash
}
if err := s.repo.Update(ctx, link); err != nil {
return nil, err
}
s.warm(ctx, link)
return link, nil
}
// Delete removes an owned link and evicts it from cache.
func (s *LinkService) Delete(ctx context.Context, id, ownerID string) error {
link, err := s.ownedLink(ctx, id, ownerID)
if err != nil {
return err
}
if err := s.repo.Delete(ctx, id, ownerID); err != nil {
return err
}
_ = s.cache.Invalidate(ctx, link.Code)
return nil
}
func (s *LinkService) ownedLink(ctx context.Context, id, ownerID string) (*domain.Link, error) {
if ownerID == "" {
return nil, domain.ErrUnauthorized
}
link, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if link.OwnerID != ownerID {
return nil, domain.ErrForbidden
}
return link, nil
}
func (s *LinkService) warm(ctx context.Context, l *domain.Link) {
_ = s.cache.SetResolution(ctx, l.Code, port.Resolution{
LongURL: l.LongURL,
HasPin: l.HasPin,
PinHash: l.PinHash,
Found: true,
}, cacheTTL)
}
// emptyWeek builds a zeroed 7-day window ending today.
func emptyWeek(now time.Time) []domain.DayCount {
out := make([]domain.DayCount, 0, 7)
for i := 6; i >= 0; i-- {
d := now.AddDate(0, 0, -i)
out = append(out, domain.DayCount{Date: d.Format("2006-01-02"), Count: 0})
}
return out
}
// IsConflict helps the HTTP layer pick a status code.
func IsConflict(err error) bool {
return errors.Is(err, domain.ErrCodeTaken) || errors.Is(err, domain.ErrReserved)
}
@@ -0,0 +1,158 @@
package service
import (
"context"
"errors"
"sync"
"testing"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/domain"
)
// --- test doubles ---
type fakeHasher struct{}
func (fakeHasher) Hash(p string) (string, error) { return "h:" + p, nil }
func (fakeHasher) Compare(hash, p string) bool { return hash == "h:"+p }
type countingRecorder struct {
mu sync.Mutex
n map[string]int
}
func newCountingRecorder() *countingRecorder { return &countingRecorder{n: map[string]int{}} }
func (r *countingRecorder) Record(code string) {
r.mu.Lock()
r.n[code]++
r.mu.Unlock()
}
func (r *countingRecorder) count(code string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.n[code]
}
func newLinkSvc() *LinkService {
return NewLinkService(memory.NewLinkRepo(), memory.NewCache(), fakeHasher{})
}
// --- tests ---
func TestCreateRandomAndMemorable(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
r, err := svc.Create(ctx, CreateInput{LongURL: "acme.com/a", Mode: domain.ModeRandom}, "")
if err != nil {
t.Fatal(err)
}
if r.Code == "" || r.LongURL != "https://acme.com/a" {
t.Fatalf("bad random link: %+v", r)
}
m, err := svc.Create(ctx, CreateInput{LongURL: "https://acme.com/b", Mode: domain.ModeMemorable}, "")
if err != nil {
t.Fatal(err)
}
if len(m.Code) < 5 {
t.Fatalf("memorable code too short: %s", m.Code)
}
}
func TestCustomRequiresAuthAndIsUnique(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, ""); !errors.Is(err, domain.ErrUnauthorized) {
t.Fatalf("anon custom should be unauthorized, got %v", err)
}
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, "u1"); err != nil {
t.Fatal(err)
}
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, "u1"); !errors.Is(err, domain.ErrCodeTaken) {
t.Fatalf("duplicate alias should conflict, got %v", err)
}
}
func TestCustomRejectsReservedPath(t *testing.T) {
svc := newLinkSvc()
if _, err := svc.Create(context.Background(), CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "dashboard"}, "u1"); !errors.Is(err, domain.ErrReserved) {
t.Fatalf("reserved alias should be rejected, got %v", err)
}
}
func TestInvalidURLRejected(t *testing.T) {
svc := newLinkSvc()
if _, err := svc.Create(context.Background(), CreateInput{LongURL: "not a url", Mode: domain.ModeRandom}, ""); !errors.Is(err, domain.ErrInvalidURL) {
t.Fatalf("expected ErrInvalidURL, got %v", err)
}
}
func TestPinLifecycleAndOwnership(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
link, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeRandom, Pin: "123456"}, "owner")
if err != nil {
t.Fatal(err)
}
if !link.HasPin {
t.Fatal("expected pin set")
}
// A different owner cannot touch it.
if _, err := svc.UpdateDestination(ctx, link.ID, "intruder", "https://evil.com"); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("expected forbidden, got %v", err)
}
// Owner removes the pin.
updated, err := svc.SetPin(ctx, link.ID, "owner", "")
if err != nil {
t.Fatal(err)
}
if updated.HasPin {
t.Fatal("pin should be removed")
}
if err := svc.Delete(ctx, link.ID, "owner"); err != nil {
t.Fatal(err)
}
if _, err := svc.List(ctx, "owner", "", 1, 10); err != nil {
t.Fatal(err)
}
}
func TestListPaginationAndSearch(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
for _, alias := range []string{"alpha", "beta", "gamma", "delta", "epsilon"} {
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com/" + alias, Mode: domain.ModeCustom, CustomAlias: alias}, "owner"); err != nil {
t.Fatal(err)
}
}
// page 1 of size 2 → 2 items, total 5
p, err := svc.List(ctx, "owner", "", 1, 2)
if err != nil {
t.Fatal(err)
}
if p.Total != 5 || len(p.Items) != 2 {
t.Fatalf("want total=5 items=2, got total=%d items=%d", p.Total, len(p.Items))
}
// page 3 → 1 item
if p, _ := svc.List(ctx, "owner", "", 3, 2); len(p.Items) != 1 {
t.Fatalf("page 3 should have 1 item, got %d", len(p.Items))
}
// search narrows
if p, _ := svc.List(ctx, "owner", "alpha", 1, 10); p.Total != 1 || p.Items[0].Code != "alpha" {
t.Fatalf("search 'alpha' should match 1, got total=%d", p.Total)
}
// stats reflect all links
s, _ := svc.Stats(ctx, "owner")
if s.TotalLinks != 5 {
t.Fatalf("stats total links want 5, got %d", s.TotalLinks)
}
}
@@ -0,0 +1,89 @@
package service
import (
"context"
"errors"
"time"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
// negativeTTL keeps unknown codes briefly cached so scanners can't hammer the DB.
const negativeTTL = 60 * time.Second
// RedirectService serves the latency-critical path. The common case (a known,
// pin-less code on a warm cache) never touches Postgres.
type RedirectService struct {
repo port.LinkRepository
cache port.LinkCache
hasher port.PasswordHasher
recorder port.ClickRecorder
}
func NewRedirectService(repo port.LinkRepository, cache port.LinkCache, hasher port.PasswordHasher, rec port.ClickRecorder) *RedirectService {
return &RedirectService{repo: repo, cache: cache, hasher: hasher, recorder: rec}
}
// Outcome is what the redirect handler should do.
type Outcome struct {
LongURL string
RequiresPin bool
}
// Resolve looks up a code (cache-first). If a PIN is required it returns
// RequiresPin without leaking the destination; the click is recorded only on an
// actual redirect.
func (s *RedirectService) Resolve(ctx context.Context, code string) (Outcome, error) {
res, err := s.resolution(ctx, code)
if err != nil {
return Outcome{}, err
}
if res.HasPin {
return Outcome{RequiresPin: true}, nil
}
s.recorder.Record(code)
return Outcome{LongURL: res.LongURL}, nil
}
// VerifyPin checks a PIN against the cached hash (no DB hit) and, on success,
// records the click and returns the destination.
func (s *RedirectService) VerifyPin(ctx context.Context, code, pin string) (string, error) {
res, err := s.resolution(ctx, code)
if err != nil {
return "", err
}
if !res.HasPin {
s.recorder.Record(code)
return res.LongURL, nil
}
if !s.hasher.Compare(res.PinHash, pin) {
return "", domain.ErrPinInvalid
}
s.recorder.Record(code)
return res.LongURL, nil
}
// resolution returns a found resolution or ErrNotFound, populating the cache
// (including a negative entry for misses).
func (s *RedirectService) resolution(ctx context.Context, code string) (port.Resolution, error) {
if res, hit, err := s.cache.GetResolution(ctx, code); err == nil && hit {
if !res.Found {
return port.Resolution{}, domain.ErrNotFound
}
return res, nil
}
link, err := s.repo.GetByCode(ctx, code)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
_ = s.cache.SetResolution(ctx, code, port.Resolution{Found: false}, negativeTTL)
return port.Resolution{}, domain.ErrNotFound
}
return port.Resolution{}, err
}
res := port.Resolution{LongURL: link.LongURL, HasPin: link.HasPin, PinHash: link.PinHash, Found: true}
_ = s.cache.SetResolution(ctx, code, res, cacheTTL)
return res, nil
}
@@ -0,0 +1,131 @@
package service
import (
"context"
"errors"
"testing"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
// countingRepo wraps the memory repo to count GetByCode calls, proving the
// cache keeps the redirect path off the "DB".
type countingRepo struct {
*memory.LinkRepo
codeReads int
}
func (r *countingRepo) GetByCode(ctx context.Context, code string) (*domain.Link, error) {
r.codeReads++
return r.LinkRepo.GetByCode(ctx, code)
}
func TestRedirectCacheAvoidsRepeatedDBReads(t *testing.T) {
repo := &countingRepo{LinkRepo: memory.NewLinkRepo()}
cache := memory.NewCache()
rec := newCountingRecorder()
links := NewLinkService(repo, cache, fakeHasher{})
redir := NewRedirectService(repo, cache, fakeHasher{}, rec)
ctx := context.Background()
created, err := links.Create(ctx, CreateInput{LongURL: "acme.com/go", Mode: domain.ModeRandom}, "")
if err != nil {
t.Fatal(err)
}
// Create already warmed the cache, so resolving never reads the repo.
for i := 0; i < 5; i++ {
out, err := redir.Resolve(ctx, created.Code)
if err != nil {
t.Fatal(err)
}
if out.LongURL != "https://acme.com/go" {
t.Fatalf("wrong target: %s", out.LongURL)
}
}
if repo.codeReads != 0 {
t.Fatalf("expected 0 DB reads on warm cache, got %d", repo.codeReads)
}
if rec.count(created.Code) != 5 {
t.Fatalf("expected 5 clicks recorded, got %d", rec.count(created.Code))
}
}
func TestRedirectColdCacheReadsOnce(t *testing.T) {
repo := &countingRepo{LinkRepo: memory.NewLinkRepo()}
cache := memory.NewCache()
links := NewLinkService(repo, cache, fakeHasher{})
redir := NewRedirectService(repo, cache, fakeHasher{}, newCountingRecorder())
ctx := context.Background()
created, _ := links.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeRandom}, "")
_ = cache.Invalidate(ctx, created.Code) // simulate a cold cache
for i := 0; i < 3; i++ {
if _, err := redir.Resolve(ctx, created.Code); err != nil {
t.Fatal(err)
}
}
if repo.codeReads != 1 {
t.Fatalf("expected exactly 1 DB read (then cached), got %d", repo.codeReads)
}
}
func TestRedirectNegativeCache(t *testing.T) {
repo := &countingRepo{LinkRepo: memory.NewLinkRepo()}
cache := memory.NewCache()
redir := NewRedirectService(repo, cache, fakeHasher{}, newCountingRecorder())
ctx := context.Background()
for i := 0; i < 4; i++ {
if _, err := redir.Resolve(ctx, "ghost"); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("expected not found, got %v", err)
}
}
if repo.codeReads != 1 {
t.Fatalf("unknown code should hit DB once then negatively cache, got %d reads", repo.codeReads)
}
if res, hit, _ := cache.GetResolution(ctx, "ghost"); !hit || res.Found {
t.Fatal("expected a negative cache entry")
}
}
func TestRedirectPinFlow(t *testing.T) {
repo := memory.NewLinkRepo()
cache := memory.NewCache()
rec := newCountingRecorder()
links := NewLinkService(repo, cache, fakeHasher{})
redir := NewRedirectService(repo, cache, fakeHasher{}, rec)
ctx := context.Background()
link, _ := links.Create(ctx, CreateInput{LongURL: "acme.com/secret", Mode: domain.ModeRandom, Pin: "424242"}, "owner")
out, err := redir.Resolve(ctx, link.Code)
if err != nil {
t.Fatal(err)
}
if !out.RequiresPin || out.LongURL != "" {
t.Fatalf("pinned link must not leak target: %+v", out)
}
if rec.count(link.Code) != 0 {
t.Fatal("no click should be recorded before unlock")
}
if _, err := redir.VerifyPin(ctx, link.Code, "000000"); !errors.Is(err, domain.ErrPinInvalid) {
t.Fatalf("wrong pin should fail, got %v", err)
}
target, err := redir.VerifyPin(ctx, link.Code, "424242")
if err != nil {
t.Fatal(err)
}
if target != "https://acme.com/secret" {
t.Fatalf("unexpected target: %s", target)
}
if rec.count(link.Code) != 1 {
t.Fatalf("expected one click after unlock, got %d", rec.count(link.Code))
}
}
var _ port.LinkRepository = (*countingRepo)(nil)
+124
View File
@@ -0,0 +1,124 @@
package service
import (
"crypto/rand"
"math/big"
"regexp"
"strings"
"github.com/snip/backend/internal/domain"
)
const base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// ReservedCodes can never be claimed as custom aliases, because these top-level
// paths are owned by the frontend / api commands.
var ReservedCodes = map[string]struct{}{
"": {},
"api": {},
"login": {},
"dashboard": {},
"assets": {},
"static": {},
"healthz": {},
"favicon.svg": {},
"favicon.ico": {},
"robots.txt": {},
"unlock": {},
}
// IsReserved reports whether a code collides with an app-owned path.
func IsReserved(code string) bool {
_, ok := ReservedCodes[strings.ToLower(code)]
return ok
}
func encodeBase62(n int64) string {
if n == 0 {
return string(base62[0])
}
var b strings.Builder
for n > 0 {
b.WriteByte(base62[n%62])
n /= 62
}
// reverse
s := []byte(b.String())
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
return string(s)
}
// scramble spreads sequential ids so codes don't look enumerable, while staying
// short. Reversible multiplicative hash over a 31-bit space.
func scramble(id int64) int64 {
const prime = 2654435761
const mod = 0x7fffffff
return ((id + 1) * prime) % mod
}
// CodeFromSequence turns a DB sequence value into the shortest unique base62
// code (no collision checks needed — the sequence guarantees uniqueness).
func CodeFromSequence(seq int64) string {
return encodeBase62(scramble(seq + 100000))
}
var aliasRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
// ValidateAlias checks a user-supplied custom code.
func ValidateAlias(alias string) error {
if !aliasRe.MatchString(alias) {
return domain.ErrInvalidAlias
}
if IsReserved(alias) {
return domain.ErrReserved
}
return nil
}
var urlRe = regexp.MustCompile(`(?i)^https?://[^\s.]+\.[^\s]{2,}$`)
// NormalizeURL ensures a scheme is present.
func NormalizeURL(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
low := strings.ToLower(s)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return s
}
return "https://" + s
}
// ValidURL validates a normalized destination.
func ValidURL(raw string) bool {
return urlRe.MatchString(raw)
}
var pinRe = regexp.MustCompile(`^\d{6}$`)
// ValidPin reports whether a string is exactly 6 digits.
func ValidPin(pin string) bool { return pinRe.MatchString(pin) }
func pick(arr []string) string {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(arr))))
return arr[n.Int64()]
}
// MemorableSlug builds an easy-to-say "adjective-noun-verb" code.
func MemorableSlug() string {
return pick(adjectives) + "-" + pick(nouns) + "-" + pick(verbs)
}
// newID mints an opaque link id.
func newID() string {
const n = 10
b := make([]byte, n)
for i := range b {
idx, _ := rand.Int(rand.Reader, big.NewInt(62))
b[i] = base62[idx.Int64()]
}
return "l_" + string(b)
}
@@ -0,0 +1,75 @@
package service
import "testing"
func TestCodeFromSequenceUniqueAndShort(t *testing.T) {
seen := map[string]bool{}
for i := int64(1); i <= 5000; i++ {
c := CodeFromSequence(i)
if c == "" {
t.Fatalf("empty code for seq %d", i)
}
if seen[c] {
t.Fatalf("collision at seq %d -> %s", i, c)
}
seen[c] = true
if len(c) > 6 {
t.Fatalf("code unexpectedly long: %s (%d chars)", c, len(c))
}
}
}
func TestValidateAlias(t *testing.T) {
cases := map[string]bool{ // alias -> valid?
"spring-launch": true,
"ab": false, // too short
"with space": false,
"good_one-2": true,
"api": false, // reserved
"dashboard": false, // reserved
"login": false, // reserved
}
for alias, valid := range cases {
err := ValidateAlias(alias)
if valid && err != nil {
t.Errorf("alias %q should be valid, got %v", alias, err)
}
if !valid && err == nil {
t.Errorf("alias %q should be invalid", alias)
}
}
}
func TestReservedBlocksAppPaths(t *testing.T) {
for _, p := range []string{"", "api", "login", "dashboard", "assets"} {
if !IsReserved(p) {
t.Errorf("%q must be reserved so it can't be claimed as a code", p)
}
}
if IsReserved("amber-otter-loop") {
t.Error("normal code should not be reserved")
}
}
func TestNormalizeAndValidateURL(t *testing.T) {
if got := NormalizeURL("acme.com/x"); got != "https://acme.com/x" {
t.Errorf("normalize added scheme wrong: %s", got)
}
if !ValidURL(NormalizeURL("acme.com/spring")) {
t.Error("expected valid url")
}
if ValidURL("not a url") {
t.Error("expected invalid url")
}
}
func TestValidPin(t *testing.T) {
if !ValidPin("123456") {
t.Error("123456 should be valid")
}
for _, bad := range []string{"12345", "1234567", "12a456", ""} {
if ValidPin(bad) {
t.Errorf("%q should be invalid", bad)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package service
// Curated pools for memorable codes, mirroring the frontend's set.
var (
adjectives = []string{
"amber", "brave", "calm", "clever", "cosmic", "crisp", "dawn", "eager",
"fizzy", "gentle", "happy", "honey", "ivory", "jolly", "keen", "lucky",
"mellow", "noble", "olive", "plush", "quartz", "rapid", "sunny", "swift",
"teal", "tidal", "vivid", "warm", "zesty", "zen",
}
nouns = []string{
"otter", "falcon", "maple", "comet", "pixel", "harbor", "meadow", "ember",
"willow", "lantern", "pebble", "cactus", "marble", "puffin", "ledger",
"cobra", "violet", "thistle", "acorn", "domino", "compass", "raven",
"saffron", "juniper", "lotus", "mango", "narwhal", "orchid", "pelican",
"quokka", "robin", "sparrow", "topaz", "umbra", "walrus", "yarrow", "zephyr",
}
verbs = []string{
"loop", "dash", "soar", "drift", "glide", "spark", "leap", "flow",
"zoom", "hop", "skip", "roam", "bounce", "swirl", "dive", "climb",
}
)