mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-19 07:28:46 +07:00
feat: first commit
This commit is contained in:
@@ -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) {}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user