mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// Package identity adapts OpenID Connect providers (Google, generic OIDC) to
|
||||
// the IdentityProvider port. Google is just an OIDC issuer, so one
|
||||
// implementation covers both required login methods.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/snip/backend/internal/port"
|
||||
)
|
||||
|
||||
// GoogleIssuer is Google's OIDC discovery issuer.
|
||||
const GoogleIssuer = "https://accounts.google.com"
|
||||
|
||||
type OIDCProvider struct {
|
||||
name string
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// NewOIDCProvider performs OIDC discovery against the issuer (one network call
|
||||
// at startup) and returns a ready provider.
|
||||
func NewOIDCProvider(ctx context.Context, name, issuer, clientID, clientSecret, redirectURL string, extraScopes []string) (*OIDCProvider, error) {
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes := append([]string{oidc.ScopeOpenID, "email", "profile"}, extraScopes...)
|
||||
return &OIDCProvider{
|
||||
name: name,
|
||||
oauth: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: scopes,
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: clientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) Name() string { return p.name }
|
||||
|
||||
func (p *OIDCProvider) AuthURL(state string) string {
|
||||
return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) Exchange(ctx context.Context, code string) (*port.Identity, error) {
|
||||
tok, err := p.oauth.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, errors.New("oidc: response missing id_token")
|
||||
}
|
||||
idToken, err := p.verifier.Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &port.Identity{Subject: claims.Sub, Email: claims.Email, Name: claims.Name}, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/port"
|
||||
)
|
||||
|
||||
type cacheEntry struct {
|
||||
res port.Resolution
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// Cache is a TTL map standing in for Redis.
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]cacheEntry
|
||||
}
|
||||
|
||||
func NewCache() *Cache {
|
||||
return &Cache{m: make(map[string]cacheEntry)}
|
||||
}
|
||||
|
||||
func (c *Cache) GetResolution(_ context.Context, code string) (port.Resolution, bool, error) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[code]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(e.expires) {
|
||||
return port.Resolution{}, false, nil
|
||||
}
|
||||
return e.res, true, nil
|
||||
}
|
||||
|
||||
func (c *Cache) SetResolution(_ context.Context, code string, r port.Resolution, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
c.m[code] = cacheEntry{res: r, expires: time.Now().Add(ttl)}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Invalidate(_ context.Context, code string) error {
|
||||
c.mu.Lock()
|
||||
delete(c.m, code)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package memory provides in-memory adapters used by tests and for running the
|
||||
// stack without Postgres/Redis (STORE=memory).
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
type LinkRepo struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*domain.Link
|
||||
seq int64
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
func NewLinkRepo() *LinkRepo {
|
||||
return &LinkRepo{byID: make(map[string]*domain.Link), clock: time.Now}
|
||||
}
|
||||
|
||||
func clone(l *domain.Link) *domain.Link {
|
||||
cp := *l
|
||||
cp.Last7Days = append([]domain.DayCount(nil), l.Last7Days...)
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Create(_ context.Context, l *domain.Link) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, e := range r.byID {
|
||||
if e.Code == l.Code {
|
||||
return domain.ErrCodeTaken
|
||||
}
|
||||
}
|
||||
r.byID[l.ID] = clone(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) GetByID(_ context.Context, id string) (*domain.Link, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
l, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return clone(l), nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) GetByCode(_ context.Context, code string) (*domain.Link, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, l := range r.byID {
|
||||
if l.Code == code {
|
||||
return clone(l), nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *LinkRepo) ListByOwner(_ context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
q := strings.ToLower(query)
|
||||
var matched []domain.Link
|
||||
for _, l := range r.byID {
|
||||
if l.OwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
if q != "" && !strings.Contains(strings.ToLower(l.Code), q) && !strings.Contains(strings.ToLower(l.LongURL), q) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, *clone(l))
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool { return matched[i].CreatedAt.After(matched[j].CreatedAt) })
|
||||
|
||||
total := len(matched)
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
end := offset + limit
|
||||
if limit <= 0 || end > total {
|
||||
end = total
|
||||
}
|
||||
return matched[offset:end], total, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) StatsByOwner(_ context.Context, ownerID string) (domain.OwnerStats, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var s domain.OwnerStats
|
||||
for _, l := range r.byID {
|
||||
if l.OwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
s.TotalLinks++
|
||||
s.TotalClicks += l.TotalClicks
|
||||
for _, d := range l.Last7Days {
|
||||
s.WeekClicks += d.Count
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Update(_ context.Context, l *domain.Link) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.byID[l.ID]; !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
r.byID[l.ID] = clone(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Delete(_ context.Context, id, ownerID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
l, ok := r.byID[id]
|
||||
if !ok || l.OwnerID != ownerID {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
delete(r.byID, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) ExistsCode(_ context.Context, code string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, l := range r.byID {
|
||||
if l.Code == code {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) NextSequence(_ context.Context) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.seq++
|
||||
return r.seq, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) RecordClicks(_ context.Context, code string, day time.Time, n int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
key := day.Format("2006-01-02")
|
||||
for _, l := range r.byID {
|
||||
if l.Code != code {
|
||||
continue
|
||||
}
|
||||
l.TotalClicks += n
|
||||
found := false
|
||||
for i := range l.Last7Days {
|
||||
if l.Last7Days[i].Date == key {
|
||||
l.Last7Days[i].Count += n
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
l.Last7Days = append(l.Last7Days, domain.DayCount{Date: key, Count: n})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepo struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*domain.User
|
||||
byKey map[string]string // provider|subject -> id
|
||||
}
|
||||
|
||||
func NewUserRepo() *UserRepo {
|
||||
return &UserRepo{byID: make(map[string]*domain.User), byKey: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (r *UserRepo) Upsert(_ context.Context, u *domain.User) (*domain.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
key := u.Provider + "|" + u.Subject
|
||||
if id, ok := r.byKey[key]; ok {
|
||||
existing := r.byID[id]
|
||||
existing.Email = u.Email
|
||||
existing.Name = u.Name
|
||||
cp := *existing
|
||||
return &cp, nil
|
||||
}
|
||||
id := randomID()
|
||||
u.ID = id
|
||||
r.byID[id] = u
|
||||
r.byKey[key] = id
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(_ context.Context, id string) (*domain.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
const hex = "0123456789abcdef"
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
out := make([]byte, 16)
|
||||
for i := range out {
|
||||
out[i] = hex[int(b[i])%16]
|
||||
}
|
||||
return "u_" + string(out)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package postgres implements the repository ports on top of pgx/pgxpool.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, subject)
|
||||
);
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS link_code_seq;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
long_url TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
has_pin BOOLEAN NOT NULL DEFAULT false,
|
||||
pin_hash TEXT NOT NULL DEFAULT '',
|
||||
owner_id TEXT,
|
||||
total_clicks BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS links_owner_idx ON links (owner_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS click_daily (
|
||||
code TEXT NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (code, day)
|
||||
);
|
||||
`
|
||||
|
||||
// migrationLockKey is an arbitrary, stable key for the advisory lock that
|
||||
// serializes schema creation across concurrently-starting services.
|
||||
const migrationLockKey = 947213
|
||||
|
||||
// Migrate opens a short-lived connection pool, runs the schema migrations, and
|
||||
// closes the pool. Safe to call concurrently: an advisory lock serialises DDL
|
||||
// across multiple callers. All statements use IF NOT EXISTS so re-runs are
|
||||
// idempotent and no data is lost.
|
||||
func Migrate(ctx context.Context, dsn string) error {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrate(ctx, pool)
|
||||
}
|
||||
|
||||
// Connect opens a pool and applies the schema (idempotent migrations). Because
|
||||
// all three commands may boot at once and each runs migrations, the DDL is
|
||||
// guarded by a session advisory lock so concurrent `CREATE ... IF NOT EXISTS`
|
||||
// can't race on the Postgres catalog.
|
||||
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(ctx, pool); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
conn, err := pool.Acquire(ctx) // advisory lock is session-scoped → one conn
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", migrationLockKey)
|
||||
|
||||
_, err = conn.Exec(ctx, schema)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
type LinkRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewLinkRepo(pool *pgxpool.Pool) *LinkRepo { return &LinkRepo{pool: pool} }
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Create(ctx context.Context, l *domain.Link) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO links (id, code, long_url, mode, has_pin, pin_hash, owner_id, total_clicks, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),0,$8)`,
|
||||
l.ID, l.Code, l.LongURL, string(l.Mode), l.HasPin, l.PinHash, l.OwnerID, l.CreatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrCodeTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const selectCols = `id, code, long_url, mode, has_pin, pin_hash, COALESCE(owner_id,''), total_clicks, created_at`
|
||||
|
||||
func scanLink(row pgx.Row) (*domain.Link, error) {
|
||||
var l domain.Link
|
||||
var mode string
|
||||
if err := row.Scan(&l.ID, &l.Code, &l.LongURL, &mode, &l.HasPin, &l.PinHash, &l.OwnerID, &l.TotalClicks, &l.CreatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
l.Mode = domain.Mode(mode)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// GetByCode is the redirect hot-path read (cache miss only). It deliberately
|
||||
// skips the 7-day stats join — the redirect doesn't need them.
|
||||
func (r *LinkRepo) GetByCode(ctx context.Context, code string) (*domain.Link, error) {
|
||||
return scanLink(r.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM links WHERE code=$1`, code))
|
||||
}
|
||||
|
||||
func (r *LinkRepo) GetByID(ctx context.Context, id string) (*domain.Link, error) {
|
||||
l, err := scanLink(r.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM links WHERE id=$1`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
weeks, err := r.weeksFor(ctx, []string{l.Code})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Last7Days = fillWeek(weeks[l.Code], time.Now().UTC())
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) ListByOwner(ctx context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) {
|
||||
// `pattern` is NULL when there's no query, so the filter is skipped.
|
||||
var pattern any
|
||||
if query != "" {
|
||||
pattern = "%" + query + "%"
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM links
|
||||
WHERE owner_id=$1 AND ($2::text IS NULL OR code ILIKE $2 OR long_url ILIKE $2)`,
|
||||
ownerID, pattern).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT `+selectCols+` FROM links
|
||||
WHERE owner_id=$1 AND ($2::text IS NULL OR code ILIKE $2 OR long_url ILIKE $2)
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4`,
|
||||
ownerID, pattern, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var links []domain.Link
|
||||
var codes []string
|
||||
for rows.Next() {
|
||||
l, err := scanLink(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
links = append(links, *l)
|
||||
codes = append(codes, l.Code)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
weeks, err := r.weeksFor(ctx, codes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range links {
|
||||
links[i].Last7Days = fillWeek(weeks[links[i].Code], time.Now().UTC())
|
||||
}
|
||||
return links, total, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) StatsByOwner(ctx context.Context, ownerID string) (domain.OwnerStats, error) {
|
||||
var s domain.OwnerStats
|
||||
if err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*), COALESCE(SUM(total_clicks),0) FROM links WHERE owner_id=$1`,
|
||||
ownerID).Scan(&s.TotalLinks, &s.TotalClicks); err != nil {
|
||||
return s, err
|
||||
}
|
||||
since := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02")
|
||||
if err := r.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(SUM(cd.count),0) FROM click_daily cd
|
||||
JOIN links l ON l.code = cd.code
|
||||
WHERE l.owner_id=$1 AND cd.day >= $2`,
|
||||
ownerID, since).Scan(&s.WeekClicks); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Update(ctx context.Context, l *domain.Link) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE links SET long_url=$2, has_pin=$3, pin_hash=$4 WHERE id=$1`,
|
||||
l.ID, l.LongURL, l.HasPin, l.PinHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Delete(ctx context.Context, id, ownerID string) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM links WHERE id=$1 AND owner_id=$2`, id, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) ExistsCode(ctx context.Context, code string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM links WHERE code=$1)`, code).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (r *LinkRepo) NextSequence(ctx context.Context) (int64, error) {
|
||||
var seq int64
|
||||
err := r.pool.QueryRow(ctx, `SELECT nextval('link_code_seq')`).Scan(&seq)
|
||||
return seq, err
|
||||
}
|
||||
|
||||
func (r *LinkRepo) RecordClicks(ctx context.Context, code string, day time.Time, n int64) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO click_daily (code, day, count) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (code, day) DO UPDATE SET count = click_daily.count + EXCLUDED.count`,
|
||||
code, day, n); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE links SET total_clicks = total_clicks + $2 WHERE code=$1`, code, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// weeksFor returns, per code, that code's day->count map for the last 7 days.
|
||||
func (r *LinkRepo) weeksFor(ctx context.Context, codes []string) (map[string]map[string]int64, error) {
|
||||
out := make(map[string]map[string]int64)
|
||||
if len(codes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
since := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02")
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT code, to_char(day,'YYYY-MM-DD'), count FROM click_daily WHERE code = ANY($1) AND day >= $2`,
|
||||
codes, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var code, day string
|
||||
var count int64
|
||||
if err := rows.Scan(&code, &day, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[code] == nil {
|
||||
out[code] = make(map[string]int64)
|
||||
}
|
||||
out[code][day] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// fillWeek expands a sparse day->count map into a dense 7-day window.
|
||||
func fillWeek(counts map[string]int64, now time.Time) []domain.DayCount {
|
||||
out := make([]domain.DayCount, 0, 7)
|
||||
for i := 6; i >= 0; i-- {
|
||||
key := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
out = append(out, domain.DayCount{Date: key, Count: counts[key]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewUserRepo(pool *pgxpool.Pool) *UserRepo { return &UserRepo{pool: pool} }
|
||||
|
||||
func (r *UserRepo) Upsert(ctx context.Context, u *domain.User) (*domain.User, error) {
|
||||
id := newUserID()
|
||||
var out domain.User
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (id, email, name, provider, subject)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (provider, subject)
|
||||
DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name
|
||||
RETURNING id, email, name, provider, subject`,
|
||||
id, u.Email, u.Name, u.Provider, u.Subject).
|
||||
Scan(&out.ID, &out.Email, &out.Name, &out.Provider, &out.Subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, name, provider, subject FROM users WHERE id=$1`, id).
|
||||
Scan(&u.ID, &u.Email, &u.Name, &u.Provider, &u.Subject)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func newUserID() string {
|
||||
const hex = "0123456789abcdef"
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
out := make([]byte, 16)
|
||||
for i := range out {
|
||||
out[i] = hex[int(b[i])%16]
|
||||
}
|
||||
return "u_" + string(out)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package rediscache implements the LinkCache port on Redis. This is the layer
|
||||
// that keeps the redirect hot path off Postgres.
|
||||
package rediscache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/snip/backend/internal/port"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
rdb *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
func New(ctx context.Context, addr, password string, db int) (*Cache, error) {
|
||||
rdb := redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
_ = rdb.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Cache{rdb: rdb, prefix: "code:"}, nil
|
||||
}
|
||||
|
||||
func (c *Cache) key(code string) string { return c.prefix + code }
|
||||
|
||||
func (c *Cache) GetResolution(ctx context.Context, code string) (port.Resolution, bool, error) {
|
||||
b, err := c.rdb.Get(ctx, c.key(code)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return port.Resolution{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return port.Resolution{}, false, err
|
||||
}
|
||||
var r port.Resolution
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return port.Resolution{}, false, err
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func (c *Cache) SetResolution(ctx context.Context, code string, r port.Resolution, ttl time.Duration) error {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.rdb.Set(ctx, c.key(code), b, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *Cache) Invalidate(ctx context.Context, code string) error {
|
||||
return c.rdb.Del(ctx, c.key(code)).Err()
|
||||
}
|
||||
|
||||
func (c *Cache) Close() error { return c.rdb.Close() }
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package security provides the password hasher and stateless session manager.
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BcryptHasher implements port.PasswordHasher.
|
||||
type BcryptHasher struct{ cost int }
|
||||
|
||||
func NewBcryptHasher() *BcryptHasher { return &BcryptHasher{cost: bcrypt.DefaultCost} }
|
||||
|
||||
func (h *BcryptHasher) Hash(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), h.cost)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (h *BcryptHasher) Compare(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// HMACSessions implements port.SessionManager with signed, stateless tokens of
|
||||
// the form base64(userID|expiryUnix).base64(hmacSHA256). No server-side store.
|
||||
type HMACSessions struct{ key []byte }
|
||||
|
||||
func NewHMACSessions(secret string) *HMACSessions {
|
||||
return &HMACSessions{key: []byte(secret)}
|
||||
}
|
||||
|
||||
func (s *HMACSessions) Issue(userID string, ttl time.Duration) (string, error) {
|
||||
payload := userID + "|" + strconv.FormatInt(time.Now().Add(ttl).Unix(), 10)
|
||||
p := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return p + "." + s.sign(p), nil
|
||||
}
|
||||
|
||||
func (s *HMACSessions) Verify(token string) (string, error) {
|
||||
p, sig, ok := strings.Cut(token, ".")
|
||||
if !ok {
|
||||
return "", errors.New("malformed token")
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(s.sign(p))) {
|
||||
return "", errors.New("bad signature")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
userID, expStr, ok := strings.Cut(string(raw), "|")
|
||||
if !ok {
|
||||
return "", errors.New("malformed payload")
|
||||
}
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if time.Now().Unix() > exp {
|
||||
return "", errors.New("session expired")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *HMACSessions) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, s.key)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package config loads runtime settings from the environment.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Storage: "postgres" (default) or "memory" (no infra, for dev/tests).
|
||||
Store string
|
||||
DatabaseURL string
|
||||
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
|
||||
// PublicURL is the externally reachable base (used to build OAuth callback
|
||||
// URLs). PostLoginRedirect is where users land after a successful login.
|
||||
PublicURL string
|
||||
PostLoginRedirect string
|
||||
|
||||
// FrontendDist is the directory of the built SPA served by the frontend cmd.
|
||||
FrontendDist string
|
||||
|
||||
// ShortDomain is the display host for short links (e.g. snip.to).
|
||||
ShortDomain string
|
||||
|
||||
SessionSecret string
|
||||
CookieSecure bool
|
||||
|
||||
// Identity providers (each optional; absent => that login button is hidden).
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
|
||||
ClickFlushInterval time.Duration
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment with sensible local defaults.
|
||||
func Load() Config {
|
||||
flush := time.Duration(envInt("CLICK_FLUSH_SECONDS", 10)) * time.Second
|
||||
return Config{
|
||||
Store: env("STORE", "postgres"),
|
||||
DatabaseURL: env("DATABASE_URL", "postgres://snip:snip@localhost:5432/snip?sslmode=disable"),
|
||||
RedisAddr: env("REDIS_ADDR", "localhost:6379"),
|
||||
RedisPassword: env("REDIS_PASSWORD", ""),
|
||||
RedisDB: envInt("REDIS_DB", 0),
|
||||
PublicURL: env("PUBLIC_URL", "http://localhost:8080"),
|
||||
PostLoginRedirect: env("POST_LOGIN_REDIRECT", "/dashboard"),
|
||||
FrontendDist: env("FRONTEND_DIST", "./web"),
|
||||
ShortDomain: env("SHORT_DOMAIN", "snip.to"),
|
||||
SessionSecret: env("SESSION_SECRET", "dev-insecure-secret-change-me"),
|
||||
CookieSecure: envBool("COOKIE_SECURE", false),
|
||||
GoogleClientID: env("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: env("GOOGLE_CLIENT_SECRET", ""),
|
||||
OIDCIssuer: env("OIDC_ISSUER", ""),
|
||||
OIDCClientID: env("OIDC_CLIENT_ID", ""),
|
||||
OIDCClientSecret: env("OIDC_CLIENT_SECRET", ""),
|
||||
ClickFlushInterval: flush,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package di wires concrete adapters into the application core. Each command
|
||||
// builds only what it needs (the frontend command touches no infrastructure).
|
||||
package di
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/adapter/identity"
|
||||
"github.com/snip/backend/internal/adapter/memory"
|
||||
"github.com/snip/backend/internal/adapter/postgres"
|
||||
"github.com/snip/backend/internal/adapter/rediscache"
|
||||
"github.com/snip/backend/internal/adapter/security"
|
||||
"github.com/snip/backend/internal/config"
|
||||
"github.com/snip/backend/internal/httpx/api"
|
||||
"github.com/snip/backend/internal/httpx/frontend"
|
||||
"github.com/snip/backend/internal/httpx/redirect"
|
||||
"github.com/snip/backend/internal/port"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
// Container holds config and constructs per-command applications.
|
||||
type Container struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func New(cfg config.Config) *Container { return &Container{cfg: cfg} }
|
||||
|
||||
// App is a runnable handler plus its resource closers.
|
||||
type App struct {
|
||||
Handler http.Handler
|
||||
closers []func()
|
||||
}
|
||||
|
||||
func (a *App) Close() {
|
||||
for i := len(a.closers) - 1; i >= 0; i-- {
|
||||
a.closers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
type infra struct {
|
||||
links port.LinkRepository
|
||||
users port.UserRepository
|
||||
cache port.LinkCache
|
||||
closers []func()
|
||||
}
|
||||
|
||||
// buildInfra picks the storage adapters based on STORE.
|
||||
func (c *Container) buildInfra(ctx context.Context) (*infra, error) {
|
||||
if c.cfg.Store == "memory" {
|
||||
return &infra{links: memory.NewLinkRepo(), users: memory.NewUserRepo(), cache: memory.NewCache()}, nil
|
||||
}
|
||||
pool, err := postgres.Connect(ctx, c.cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache, err := rediscache.New(ctx, c.cfg.RedisAddr, c.cfg.RedisPassword, c.cfg.RedisDB)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &infra{
|
||||
links: postgres.NewLinkRepo(pool),
|
||||
users: postgres.NewUserRepo(pool),
|
||||
cache: cache,
|
||||
closers: []func(){pool.Close, func() { _ = cache.Close() }},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildFrontend constructs the static SPA server (no DB/Redis).
|
||||
func (c *Container) BuildFrontend() *App {
|
||||
return &App{Handler: frontend.New(c.cfg.FrontendDist).Handler()}
|
||||
}
|
||||
|
||||
// BuildAPI constructs the /api/v1 server. OIDC provider discovery runs in the
|
||||
// background so the HTTP server (and its health endpoint) starts immediately.
|
||||
// Providers become available once discovery completes; until then the /auth/*
|
||||
// endpoints return an empty provider list and login buttons are hidden.
|
||||
func (c *Container) BuildAPI(ctx context.Context) (*App, error) {
|
||||
inf, err := c.buildInfra(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasher := security.NewBcryptHasher()
|
||||
sessions := security.NewHMACSessions(c.cfg.SessionSecret)
|
||||
linkSvc := service.NewLinkService(inf.links, inf.cache, hasher)
|
||||
authSvc := service.NewAuthService(nil, inf.users, sessions) // providers injected below
|
||||
h := api.New(linkSvc, authSvc, sessions, c.cfg).Handler()
|
||||
|
||||
// Discover OIDC providers in the background; inject them once ready.
|
||||
go func() {
|
||||
providers := c.buildProviders(ctx)
|
||||
authSvc.SetProviders(providers)
|
||||
log.Printf("di: %d identity provider(s) ready", len(providers))
|
||||
}()
|
||||
|
||||
return &App{Handler: h, closers: inf.closers}, nil
|
||||
}
|
||||
|
||||
// BuildRedirect constructs the redirect server and starts the click flusher.
|
||||
func (c *Container) BuildRedirect(ctx context.Context) (*App, error) {
|
||||
inf, err := c.buildInfra(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasher := security.NewBcryptHasher()
|
||||
recorder := service.NewAsyncClickRecorder(inf.links, c.cfg.ClickFlushInterval)
|
||||
recorder.Start(ctx)
|
||||
redirectSvc := service.NewRedirectService(inf.links, inf.cache, hasher, recorder)
|
||||
h := redirect.New(redirectSvc, c.cfg.ShortDomain, c.cfg.PublicURL).Handler()
|
||||
closers := append([]func(){recorder.Stop}, inf.closers...)
|
||||
return &App{Handler: h, closers: closers}, nil
|
||||
}
|
||||
|
||||
// buildProviders configures the available login methods. Each is optional: if
|
||||
// its credentials are absent (or discovery fails) the button simply won't show.
|
||||
func (c *Container) buildProviders(ctx context.Context) []port.IdentityProvider {
|
||||
var providers []port.IdentityProvider
|
||||
callback := func(name string) string {
|
||||
return c.cfg.PublicURL + "/api/v1/auth/" + name + "/callback"
|
||||
}
|
||||
|
||||
if c.cfg.GoogleClientID != "" && c.cfg.GoogleClientSecret != "" {
|
||||
if p := dialProvider(ctx, "google", identity.GoogleIssuer,
|
||||
c.cfg.GoogleClientID, c.cfg.GoogleClientSecret, callback("google")); p != nil {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
if c.cfg.OIDCIssuer != "" && c.cfg.OIDCClientID != "" {
|
||||
if p := dialProvider(ctx, "oidc", c.cfg.OIDCIssuer,
|
||||
c.cfg.OIDCClientID, c.cfg.OIDCClientSecret, callback("oidc")); p != nil {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// dialProvider performs OIDC discovery with a short retry, so the api command
|
||||
// can start alongside a still-booting (mock) provider instead of silently
|
||||
// disabling login on a transient connection error.
|
||||
func dialProvider(ctx context.Context, name, issuer, clientID, secret, redirect string) port.IdentityProvider {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 15; attempt++ {
|
||||
p, err := identity.NewOIDCProvider(ctx, name, issuer, clientID, secret, redirect, nil)
|
||||
if err == nil {
|
||||
log.Printf("di: %s provider ready (issuer %s)", name, issuer)
|
||||
return p
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
log.Printf("di: %s provider disabled after retries: %v", name, lastErr)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors crossing the port boundary. Adapters translate infra errors
|
||||
// into these; HTTP layer maps these to status codes.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrCodeTaken = errors.New("code already taken")
|
||||
ErrReserved = errors.New("code is reserved")
|
||||
ErrInvalidURL = errors.New("invalid destination url")
|
||||
ErrInvalidAlias = errors.New("invalid custom alias")
|
||||
ErrInvalidPin = errors.New("pin must be exactly 6 digits")
|
||||
ErrUnauthorized = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("not allowed")
|
||||
ErrPinRequired = errors.New("pin required")
|
||||
ErrPinInvalid = errors.New("incorrect pin")
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Mode is how a short code was produced.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeRandom Mode = "random"
|
||||
ModeMemorable Mode = "memorable"
|
||||
ModeCustom Mode = "custom"
|
||||
)
|
||||
|
||||
// DayCount is a single day's click total.
|
||||
type DayCount struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// Link is the aggregate root: a short code pointing at a destination.
|
||||
type Link struct {
|
||||
ID string
|
||||
Code string
|
||||
LongURL string
|
||||
Mode Mode
|
||||
HasPin bool
|
||||
PinHash string // bcrypt hash; never serialized out of the system
|
||||
OwnerID string // empty for anonymous links
|
||||
CreatedAt time.Time
|
||||
TotalClicks int64
|
||||
Last7Days []DayCount
|
||||
}
|
||||
|
||||
// OwnerStats are the dashboard aggregate tiles, computed in the DB so the
|
||||
// client never has to fetch every link to total them up.
|
||||
type OwnerStats struct {
|
||||
TotalLinks int `json:"totalLinks"`
|
||||
TotalClicks int64 `json:"totalClicks"`
|
||||
WeekClicks int64 `json:"weekClicks"`
|
||||
}
|
||||
|
||||
// User is an authenticated account, keyed by (provider, subject).
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Provider string
|
||||
Subject string
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/adapter/memory"
|
||||
"github.com/snip/backend/internal/adapter/security"
|
||||
"github.com/snip/backend/internal/config"
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/httpx/api"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
h http.Handler
|
||||
token string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
repo := memory.NewLinkRepo()
|
||||
cache := memory.NewCache()
|
||||
users := memory.NewUserRepo()
|
||||
hasher := security.NewBcryptHasher()
|
||||
sessions := security.NewHMACSessions("test-secret")
|
||||
|
||||
links := service.NewLinkService(repo, cache, hasher)
|
||||
auth := service.NewAuthService(nil, users, sessions)
|
||||
cfg := config.Config{PostLoginRedirect: "/dashboard"}
|
||||
|
||||
user, err := users.Upsert(context.Background(), &domain.User{
|
||||
Email: "[email protected]", Name: "Sam", Provider: "test", Subject: "s1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := sessions.Issue(user.ID, time.Hour)
|
||||
|
||||
return &harness{h: api.New(links, auth, sessions, cfg).Handler(), token: token}
|
||||
}
|
||||
|
||||
func (h *harness) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if body != "" {
|
||||
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
if token != "" {
|
||||
r.AddCookie(&http.Cookie{Name: "snip_session", Value: token})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeLink(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("bad json (%d): %s", w.Code, w.Body.String())
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestAnonCanCreateRandomButNotList(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/x","mode":"random"}`, "")
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if code, _ := decodeLink(t, w)["code"].(string); code == "" {
|
||||
t.Fatal("expected a code")
|
||||
}
|
||||
|
||||
if w := h.do(t, http.MethodGet, "/api/v1/links", "", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anon list: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomAndReservedAndConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
// reserved alias → 409
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"dashboard"}`, h.token); w.Code != http.StatusConflict {
|
||||
t.Fatalf("reserved: want 409, got %d", w.Code)
|
||||
}
|
||||
// valid custom → 201
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusCreated {
|
||||
t.Fatalf("custom: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// duplicate → 409
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusConflict {
|
||||
t.Fatalf("dupe: want 409, got %d", w.Code)
|
||||
}
|
||||
// anon custom → 401
|
||||
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"x9z"}`, ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anon custom: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLifecycleWithPin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
// create
|
||||
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/a","mode":"random"}`, h.token)
|
||||
id := decodeLink(t, w)["id"].(string)
|
||||
|
||||
// list shows it
|
||||
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d", w.Code)
|
||||
}
|
||||
if total := listTotal(t, w); total != 1 {
|
||||
t.Fatalf("want total 1, got %d", total)
|
||||
}
|
||||
|
||||
// patch destination
|
||||
w = h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"acme.com/b"}`, h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["longUrl"] != "https://acme.com/b" {
|
||||
t.Fatalf("patch failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// set pin
|
||||
w = h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"123456"}`, h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != true {
|
||||
t.Fatalf("set pin failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// bad pin → 400
|
||||
if w := h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"12"}`, h.token); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad pin: want 400, got %d", w.Code)
|
||||
}
|
||||
// remove pin
|
||||
w = h.do(t, http.MethodDelete, "/api/v1/links/"+id+"/pin", "", h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != false {
|
||||
t.Fatalf("remove pin failed: %d", w.Code)
|
||||
}
|
||||
|
||||
// delete
|
||||
if w := h.do(t, http.MethodDelete, "/api/v1/links/"+id, "", h.token); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: want 204, got %d", w.Code)
|
||||
}
|
||||
// gone from list
|
||||
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
|
||||
if total := listTotal(t, w); total != 0 {
|
||||
t.Fatalf("want 0 links after delete, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func listTotal(t *testing.T, w *httptest.ResponseRecorder) int {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad list json: %s", w.Body.String())
|
||||
}
|
||||
return resp.Total
|
||||
}
|
||||
|
||||
func TestPatchForbiddenForOtherUser(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"random"}`, h.token)
|
||||
id := decodeLink(t, w)["id"].(string)
|
||||
|
||||
other := security.NewHMACSessions("test-secret")
|
||||
otherToken, _ := other.Issue("someone-else", time.Hour)
|
||||
if w := h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"https://evil.com"}`, otherToken); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403 for non-owner, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeRequiresSession(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me anon: want 401, got %d", w.Code)
|
||||
}
|
||||
w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", h.token)
|
||||
if w.Code != http.StatusOK || decodeLink(t, w)["email"] != "[email protected]" {
|
||||
t.Fatalf("me: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if w := h.do(t, http.MethodGet, "/api/v1/healthz", "", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("healthz: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
// /api/v1/auth/...
|
||||
// GET providers
|
||||
// GET {provider}/login
|
||||
// GET {provider}/callback
|
||||
// GET me
|
||||
// POST logout
|
||||
func (a *API) authRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
rest := pathTail(r.URL.Path, "/api/v1/auth/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
|
||||
switch {
|
||||
case len(parts) == 1 && parts[0] == "providers":
|
||||
writeJSON(w, http.StatusOK, map[string][]string{"providers": a.auth.Providers()})
|
||||
case len(parts) == 1 && parts[0] == "me":
|
||||
a.me(w, r)
|
||||
case len(parts) == 1 && parts[0] == "logout":
|
||||
a.clearCookie(w, sessionCookie)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case len(parts) == 2 && parts[1] == "login":
|
||||
a.beginLogin(w, r, parts[0])
|
||||
case len(parts) == 2 && parts[1] == "callback":
|
||||
a.callback(w, r, parts[0])
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err := a.auth.Me(r.Context(), c.Value)
|
||||
if err != nil {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userDTO{ID: user.ID, Email: user.Email, Name: user.Name})
|
||||
}
|
||||
|
||||
func (a *API) beginLogin(w http.ResponseWriter, r *http.Request, provider string) {
|
||||
state := randomState()
|
||||
url, err := a.auth.AuthURL(provider, state)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
// Double-submit: stash state in a short-lived cookie, compare on callback.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookie, Value: state, Path: "/", HttpOnly: true,
|
||||
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *API) callback(w http.ResponseWriter, r *http.Request, provider string) {
|
||||
cookie, err := r.Cookie(stateCookie)
|
||||
if err != nil || cookie.Value == "" || cookie.Value != r.URL.Query().Get("state") {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
a.clearCookie(w, stateCookie)
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
_, token, err := a.auth.Complete(r.Context(), provider, code)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
a.setSession(w, token)
|
||||
http.Redirect(w, r, a.cfg.PostLoginRedirect, http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
// linkDTO is the JSON shape returned to the SPA. It intentionally matches the
|
||||
// frontend's `ShortLink` type so the mock API is a drop-in swap. PinHash is
|
||||
// never included.
|
||||
type linkDTO struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
LongURL string `json:"longUrl"`
|
||||
Mode string `json:"mode"`
|
||||
HasPin bool `json:"hasPin"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TotalClicks int64 `json:"totalClicks"`
|
||||
Last7Days []domain.DayCount `json:"last7Days"`
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
func toDTO(l *domain.Link) linkDTO {
|
||||
week := l.Last7Days
|
||||
if week == nil {
|
||||
week = []domain.DayCount{}
|
||||
}
|
||||
return linkDTO{
|
||||
ID: l.ID,
|
||||
Code: l.Code,
|
||||
LongURL: l.LongURL,
|
||||
Mode: string(l.Mode),
|
||||
HasPin: l.HasPin,
|
||||
CreatedAt: l.CreatedAt.Format(time.RFC3339),
|
||||
TotalClicks: l.TotalClicks,
|
||||
Last7Days: week,
|
||||
Owned: l.OwnerID != "",
|
||||
}
|
||||
}
|
||||
|
||||
// listResponse is the paginated link listing returned to the dashboard.
|
||||
type listResponse struct {
|
||||
Items []linkDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
LongURL string `json:"longUrl"`
|
||||
Mode string `json:"mode"`
|
||||
CustomAlias string `json:"customAlias"`
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
|
||||
type updateReq struct {
|
||||
LongURL string `json:"longUrl"`
|
||||
}
|
||||
|
||||
type pinReq struct {
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
// /api/v1/links — POST create (auth optional), GET list (auth required)
|
||||
func (a *API) linksCollection(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
a.createLink(w, r)
|
||||
case http.MethodGet:
|
||||
a.listLinks(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) createLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req createReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, domain.ErrInvalidURL)
|
||||
return
|
||||
}
|
||||
mode := domain.Mode(req.Mode)
|
||||
if mode != domain.ModeRandom && mode != domain.ModeMemorable && mode != domain.ModeCustom {
|
||||
mode = domain.ModeRandom
|
||||
}
|
||||
link, err := a.links.Create(r.Context(), service.CreateInput{
|
||||
LongURL: req.LongURL,
|
||||
Mode: mode,
|
||||
CustomAlias: req.CustomAlias,
|
||||
Pin: req.Pin,
|
||||
}, a.userID(r))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toDTO(link))
|
||||
}
|
||||
|
||||
func (a *API) listLinks(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
|
||||
|
||||
result, err := a.links.List(r.Context(), a.userID(r), q.Get("q"), page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]linkDTO, 0, len(result.Items))
|
||||
for i := range result.Items {
|
||||
items = append(items, toDTO(&result.Items[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listResponse{
|
||||
Items: items,
|
||||
Total: result.Total,
|
||||
Page: result.Page,
|
||||
PageSize: result.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) linkStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := a.links.Stats(r.Context(), a.userID(r))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// /api/v1/links/{id} — PATCH, DELETE
|
||||
// /api/v1/links/{id}/pin — PUT, DELETE
|
||||
func (a *API) linkItem(w http.ResponseWriter, r *http.Request) {
|
||||
rest := pathTail(r.URL.Path, "/api/v1/links/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
id := parts[0]
|
||||
if id == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := a.userID(r)
|
||||
|
||||
// /{id}/pin
|
||||
if len(parts) == 2 && parts[1] == "pin" {
|
||||
a.managePin(w, r, id, owner)
|
||||
return
|
||||
}
|
||||
if len(parts) != 1 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
var req updateReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, domain.ErrInvalidURL)
|
||||
return
|
||||
}
|
||||
link, err := a.links.UpdateDestination(r.Context(), id, owner, req.LongURL)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(link))
|
||||
case http.MethodDelete:
|
||||
if err := a.links.Delete(r.Context(), id, owner); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) managePin(w http.ResponseWriter, r *http.Request, id, owner string) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var req pinReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, domain.ErrInvalidPin)
|
||||
return
|
||||
}
|
||||
link, err := a.links.SetPin(r.Context(), id, owner, req.Pin)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(link))
|
||||
case http.MethodDelete:
|
||||
link, err := a.links.SetPin(r.Context(), id, owner, "")
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(link))
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package api is the inbound HTTP adapter for the /api/v1 surface.
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/config"
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/port"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "snip_session"
|
||||
stateCookie = "snip_oauth_state"
|
||||
)
|
||||
|
||||
// API wires the link and auth use cases to HTTP.
|
||||
type API struct {
|
||||
links *service.LinkService
|
||||
auth *service.AuthService
|
||||
sessions port.SessionManager
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func New(links *service.LinkService, auth *service.AuthService, sessions port.SessionManager, cfg config.Config) *API {
|
||||
return &API{links: links, auth: auth, sessions: sessions, cfg: cfg}
|
||||
}
|
||||
|
||||
// Handler returns the routed, CORS-wrapped /api/v1 handler.
|
||||
func (a *API) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/healthz", a.health)
|
||||
mux.HandleFunc("/api/v1/config", a.config)
|
||||
mux.HandleFunc("/api/v1/links/stats", a.linkStats) // exact: wins over /links/
|
||||
mux.HandleFunc("/api/v1/links", a.linksCollection)
|
||||
mux.HandleFunc("/api/v1/links/", a.linkItem)
|
||||
mux.HandleFunc("/api/v1/auth/", a.authRoutes)
|
||||
return cors(mux)
|
||||
}
|
||||
|
||||
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// config exposes client-relevant, non-secret settings (e.g. the short domain),
|
||||
// so the frontend doesn't hardcode them.
|
||||
func (a *API) config(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"shortDomain": a.cfg.ShortDomain})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrUnauthorized):
|
||||
status = http.StatusUnauthorized
|
||||
case errors.Is(err, domain.ErrForbidden):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, domain.ErrCodeTaken), errors.Is(err, domain.ErrReserved):
|
||||
status = http.StatusConflict
|
||||
case errors.Is(err, domain.ErrInvalidURL), errors.Is(err, domain.ErrInvalidAlias),
|
||||
errors.Is(err, domain.ErrInvalidPin), errors.Is(err, domain.ErrPinInvalid),
|
||||
errors.Is(err, domain.ErrPinRequired):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// userID extracts and verifies the caller's session, or "" if anonymous.
|
||||
func (a *API) userID(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
id, err := a.sessions.Verify(c.Value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (a *API) setSession(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) clearCookie(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name, Value: "", Path: "/", HttpOnly: true,
|
||||
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func randomState() string {
|
||||
b := make([]byte, 24)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func decode(r *http.Request, v any) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// cors reflects the request Origin and allows credentials (cookies). In
|
||||
// production the SPA and API are same-origin behind a gateway; this keeps local
|
||||
// dev (vite :5173 → api :8080) working.
|
||||
func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// pathTail returns the part of the request path after a prefix.
|
||||
func pathTail(path, prefix string) string {
|
||||
return strings.TrimPrefix(path, prefix)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package frontend_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/snip/backend/internal/httpx/frontend"
|
||||
)
|
||||
|
||||
func tempDist(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><div id=root>SPA</div>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "assets", "app.js"), []byte("console.log('hi')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestServesSPARoutes(t *testing.T) {
|
||||
h := frontend.New(tempDist(t)).Handler()
|
||||
|
||||
for _, route := range []string{"/", "/login", "/dashboard"} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, route, nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SPA") {
|
||||
t.Fatalf("%s should render index, got %d", route, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesAssets(t *testing.T) {
|
||||
h := frontend.New(tempDist(t)).Handler()
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "console.log") {
|
||||
t.Fatalf("asset should be served, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathIs404(t *testing.T) {
|
||||
h := frontend.New(tempDist(t)).Handler()
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/some/short-code", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown path: want 404 (redirect cmd owns it), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package frontend serves the built SPA. It owns exactly the app routes
|
||||
// (/, /login, /dashboard) plus static assets; everything else is 404 here and
|
||||
// handled by the redirect command in production.
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
dist string
|
||||
fs http.Handler
|
||||
spa map[string]bool
|
||||
indexAbs string
|
||||
}
|
||||
|
||||
func New(dist string) *Server {
|
||||
return &Server{
|
||||
dist: dist,
|
||||
fs: http.FileServer(http.Dir(dist)),
|
||||
spa: map[string]bool{"/": true, "/login": true, "/dashboard": true},
|
||||
indexAbs: filepath.Join(dist, "index.html"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("/", s.serve)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
clean := path.Clean(r.URL.Path)
|
||||
|
||||
// App routes always render the SPA shell.
|
||||
if s.spa[clean] {
|
||||
http.ServeFile(w, r, s.indexAbs)
|
||||
return
|
||||
}
|
||||
|
||||
// Real static file (assets, favicon, …)?
|
||||
if clean != "/" {
|
||||
full := filepath.Join(s.dist, filepath.FromSlash(clean))
|
||||
if st, err := os.Stat(full); err == nil && !st.IsDir() {
|
||||
s.fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown path: not this command's concern.
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package redirect
|
||||
|
||||
import "html"
|
||||
|
||||
// notFoundHTML is a tiny, self-contained 404 in the snip style.
|
||||
func notFoundHTML(shortHost, homeURL string) string {
|
||||
h := html.EscapeString(shortHost)
|
||||
href := html.EscapeString(homeURL)
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Link not found</title><style>
|
||||
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020}}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink);
|
||||
font-family:ui-sans-serif,system-ui,sans-serif;text-align:center;padding:24px}
|
||||
.card{background:var(--surface);border:1.5px solid var(--line);border-radius:24px;padding:36px;max-width:360px}
|
||||
h1{font-size:54px;margin:0;letter-spacing:-.03em}
|
||||
p{color:var(--muted);margin:8px 0 20px}
|
||||
a{display:inline-block;background:var(--accent);color:#16170f;text-decoration:none;font-weight:600;
|
||||
padding:12px 20px;border-radius:14px}
|
||||
</style></head><body><div class="card"><h1>404</h1>
|
||||
<p>This short link doesn't exist or was removed.</p>
|
||||
<a href="` + href + `">Go to ` + h + `</a></div></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// pinData feeds the enter-PIN template.
|
||||
type pinData struct {
|
||||
Code string
|
||||
ShortHost string
|
||||
ActionPath string
|
||||
HasError bool
|
||||
}
|
||||
|
||||
// The page is fully self-contained (no external CSS/JS/fonts) so it paints in a
|
||||
// single round trip — the redirect path must stay fast. It mirrors the snip
|
||||
// look: warm paper + ink, electric-lime accent, with a dark-mode variant.
|
||||
var pinTmpl = template.Must(template.New("pin").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#16170f">
|
||||
<title>Enter PIN · {{.ShortHost}}/{{.Code}}</title>
|
||||
<style>
|
||||
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e;--accent-ink:#16170f}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020;--accent:#c6f24e}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||
background:var(--bg);color:var(--ink);
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
background-image:radial-gradient(60% 50% at 12% 8%,rgba(198,242,78,.30),transparent 60%),radial-gradient(60% 50% at 90% 90%,rgba(198,242,78,.22),transparent 60%)}
|
||||
.card{width:100%;max-width:380px;background:var(--surface);border:1.5px solid var(--line);
|
||||
border-radius:24px;padding:28px;box-shadow:0 30px 80px -30px rgba(0,0,0,.45);
|
||||
animation:pop .45s cubic-bezier(.2,.9,.25,1.2)}
|
||||
@keyframes pop{from{opacity:0;transform:translateY(16px) scale(.96)}to{opacity:1;transform:none}}
|
||||
.lock{width:48px;height:48px;border-radius:16px;background:var(--ink);display:grid;place-items:center;margin-bottom:18px}
|
||||
.lock svg{width:24px;height:24px}
|
||||
h1{font-size:24px;margin:0 0 6px;letter-spacing:-.02em}
|
||||
p{margin:0 0 20px;color:var(--muted);font-size:14px;line-height:1.5}
|
||||
p b{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.pins{display:flex;gap:8px;margin-bottom:14px}
|
||||
.pins input{flex:1;width:100%;height:54px;text-align:center;font-size:22px;font-weight:700;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
|
||||
background:var(--bg);border:1.5px solid var(--line);border-radius:14px;outline:none;transition:transform .12s,border-color .12s}
|
||||
.pins input:focus{transform:translateY(-2px) scale(1.05);border-color:var(--accent)}
|
||||
.err{color:#ef4444;font-size:13px;font-weight:600;margin:0 0 14px;min-height:18px}
|
||||
button{width:100%;height:52px;border:0;border-radius:16px;background:var(--accent);color:var(--accent-ink);
|
||||
font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px -8px rgba(198,242,78,.6);transition:transform .12s}
|
||||
button:active{transform:scale(.96)}
|
||||
.foot{margin-top:16px;text-align:center;font-size:12px;color:var(--muted)}
|
||||
.foot b{color:var(--ink)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="lock"><svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="11" rx="3" fill="#c6f24e"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#c6f24e" stroke-width="2.4" fill="none"/><circle cx="12" cy="15.5" r="1.7" fill="#16170f"/></svg></div>
|
||||
<h1>This link is protected</h1>
|
||||
<p>Enter the 6-digit PIN to continue to <b>{{.ShortHost}}/{{.Code}}</b>.</p>
|
||||
<form method="post" action="{{.ActionPath}}" id="f" autocomplete="off">
|
||||
<div class="pins" id="pins">
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 1" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 2" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 3" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 4" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 5" required>
|
||||
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 6" required>
|
||||
</div>
|
||||
<p class="err">{{if .HasError}}That PIN didn't match. Try again.{{end}}</p>
|
||||
<input type="hidden" name="pin" id="pin">
|
||||
<button type="submit">Unlock →</button>
|
||||
</form>
|
||||
<div class="foot">Secured by <b>{{.ShortHost}}</b></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var boxes=[].slice.call(document.querySelectorAll('#pins input')),hidden=document.getElementById('pin'),f=document.getElementById('f');
|
||||
function sync(){hidden.value=boxes.map(function(b){return b.value}).join('')}
|
||||
boxes.forEach(function(b,i){
|
||||
b.addEventListener('input',function(){
|
||||
b.value=b.value.replace(/\D/g,'').slice(0,1);
|
||||
if(b.value&&i<boxes.length-1)boxes[i+1].focus();
|
||||
sync();
|
||||
if(hidden.value.length===6)f.submit();
|
||||
});
|
||||
b.addEventListener('keydown',function(e){if(e.key==='Backspace'&&!b.value&&i>0)boxes[i-1].focus()});
|
||||
b.addEventListener('paste',function(e){
|
||||
var d=(e.clipboardData.getData('text')||'').replace(/\D/g,'').slice(0,6);
|
||||
if(!d)return;e.preventDefault();
|
||||
d.split('').forEach(function(c,j){if(boxes[j])boxes[j].value=c});
|
||||
boxes[Math.min(d.length,5)].focus();sync();if(d.length===6)f.submit();
|
||||
});
|
||||
});
|
||||
if(boxes[0])boxes[0].focus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// renderPinPage writes the enter-PIN page. status is 200 on first view, 401 on
|
||||
// a wrong-PIN retry.
|
||||
func renderPinPage(w http.ResponseWriter, status int, d pinData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = pinTmpl.Execute(w, d)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package redirect_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/snip/backend/internal/adapter/memory"
|
||||
"github.com/snip/backend/internal/adapter/security"
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/httpx/redirect"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) (*service.LinkService, http.Handler) {
|
||||
t.Helper()
|
||||
repo := memory.NewLinkRepo()
|
||||
cache := memory.NewCache()
|
||||
hasher := security.NewBcryptHasher()
|
||||
links := service.NewLinkService(repo, cache, hasher)
|
||||
redir := service.NewRedirectService(repo, cache, hasher, service.NoopRecorder{})
|
||||
srv := redirect.New(redir, "snip.to", "https://snip.to")
|
||||
return links, srv.Handler()
|
||||
}
|
||||
|
||||
func TestRedirectFound(t *testing.T) {
|
||||
links, h := setup(t)
|
||||
l, _ := links.Create(context.Background(), service.CreateInput{LongURL: "acme.com/go", Mode: domain.ModeRandom}, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+l.Code, nil))
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("want 302, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != "https://acme.com/go" {
|
||||
t.Fatalf("bad location: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectNotFound(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ghost", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApexRedirectsHome(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "https://snip.to" {
|
||||
t.Fatalf("apex should redirect home, got %d %s", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinPageAndUnlock(t *testing.T) {
|
||||
links, h := setup(t)
|
||||
l, _ := links.Create(context.Background(), service.CreateInput{LongURL: "acme.com/secret", Mode: domain.ModeRandom, Pin: "424242"}, "owner")
|
||||
|
||||
// GET shows the enter-PIN page, not a redirect.
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+l.Code, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("pin page: want 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "protected") || !strings.Contains(body, l.Code) {
|
||||
t.Fatal("pin page missing expected content")
|
||||
}
|
||||
if strings.Contains(body, "acme.com/secret") {
|
||||
t.Fatal("pin page must not leak the destination")
|
||||
}
|
||||
|
||||
// Wrong pin → 401 + error page.
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, postForm("/"+l.Code, "pin", "000000"))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong pin: want 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Correct pin → 302 to destination.
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, postForm("/"+l.Code, "pin", "424242"))
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "https://acme.com/secret" {
|
||||
t.Fatalf("unlock: want 302 to target, got %d %s", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func postForm(path, key, val string) *http.Request {
|
||||
form := url.Values{key: {val}}
|
||||
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package redirect is the inbound adapter for the high-traffic redirect path.
|
||||
// It resolves a code to a destination (cache-first) and renders the enter-PIN
|
||||
// page for protected links.
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
"github.com/snip/backend/internal/service"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
svc *service.RedirectService
|
||||
shortHost string
|
||||
homeURL string
|
||||
}
|
||||
|
||||
func New(svc *service.RedirectService, shortHost, homeURL string) *Server {
|
||||
return &Server{svc: svc, shortHost: shortHost, homeURL: homeURL}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("/", s.handle)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.Trim(r.URL.Path, "/")
|
||||
if code == "" {
|
||||
http.Redirect(w, r, s.homeURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if strings.Contains(code, "/") {
|
||||
s.notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
out, err := s.svc.Resolve(r.Context(), code)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
s.notFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if out.RequiresPin {
|
||||
renderPinPage(w, http.StatusOK, pinData{Code: code, ShortHost: s.shortHost, ActionPath: "/" + code})
|
||||
return
|
||||
}
|
||||
redirectOut(w, r, out.LongURL)
|
||||
|
||||
case http.MethodPost:
|
||||
_ = r.ParseForm()
|
||||
long, err := s.svc.VerifyPin(r.Context(), code, r.FormValue("pin"))
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
s.notFound(w)
|
||||
case errors.Is(err, domain.ErrPinInvalid):
|
||||
renderPinPage(w, http.StatusUnauthorized, pinData{Code: code, ShortHost: s.shortHost, ActionPath: "/" + code, HasError: true})
|
||||
case err != nil:
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
default:
|
||||
redirectOut(w, r, long)
|
||||
}
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func redirectOut(w http.ResponseWriter, r *http.Request, target string) {
|
||||
// Don't let intermediaries cache the bounce.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) notFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(notFoundHTML(s.shortHost, s.homeURL)))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package port declares the hexagon's boundaries. The core (service) depends
|
||||
// only on these interfaces; adapters (postgres, redis, oidc, …) implement them.
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
// LinkRepository is the source of truth (Postgres in prod, in-memory in tests).
|
||||
type LinkRepository interface {
|
||||
Create(ctx context.Context, l *domain.Link) error
|
||||
GetByID(ctx context.Context, id string) (*domain.Link, error)
|
||||
GetByCode(ctx context.Context, code string) (*domain.Link, error)
|
||||
// ListByOwner returns one page of the owner's links matching the (optional)
|
||||
// query, newest first, plus the total count for that filter — all done in
|
||||
// the database so the API never loads every row.
|
||||
ListByOwner(ctx context.Context, ownerID, query string, limit, offset int) (items []domain.Link, total int, err error)
|
||||
// StatsByOwner returns the aggregate dashboard tiles for an owner.
|
||||
StatsByOwner(ctx context.Context, ownerID string) (domain.OwnerStats, error)
|
||||
Update(ctx context.Context, l *domain.Link) error
|
||||
Delete(ctx context.Context, id, ownerID string) error
|
||||
ExistsCode(ctx context.Context, code string) (bool, error)
|
||||
// NextSequence returns a monotonically increasing int used to mint the
|
||||
// shortest-possible unique base62 code for ModeRandom.
|
||||
NextSequence(ctx context.Context) (int64, error)
|
||||
// RecordClicks adds n clicks for a code on a given day (batched, off the
|
||||
// hot path) and bumps the running total.
|
||||
RecordClicks(ctx context.Context, code string, day time.Time, n int64) error
|
||||
}
|
||||
|
||||
// Resolution is the tiny payload the redirect hot path needs. Cached in Redis
|
||||
// so the common case never touches Postgres.
|
||||
type Resolution struct {
|
||||
LongURL string
|
||||
HasPin bool
|
||||
PinHash string
|
||||
Found bool // false = negatively cached (known-missing code)
|
||||
}
|
||||
|
||||
// LinkCache fronts the repository for read-heavy redirect traffic.
|
||||
type LinkCache interface {
|
||||
GetResolution(ctx context.Context, code string) (res Resolution, hit bool, err error)
|
||||
SetResolution(ctx context.Context, code string, r Resolution, ttl time.Duration) error
|
||||
Invalidate(ctx context.Context, code string) error
|
||||
}
|
||||
|
||||
// UserRepository persists authenticated accounts.
|
||||
type UserRepository interface {
|
||||
Upsert(ctx context.Context, u *domain.User) (*domain.User, error)
|
||||
GetByID(ctx context.Context, id string) (*domain.User, error)
|
||||
}
|
||||
|
||||
// Identity is the normalized result of an OAuth/OIDC exchange.
|
||||
type Identity struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
// IdentityProvider abstracts a login method (Google OAuth, generic OIDC).
|
||||
type IdentityProvider interface {
|
||||
Name() string
|
||||
AuthURL(state string) string
|
||||
Exchange(ctx context.Context, code string) (*Identity, error)
|
||||
}
|
||||
|
||||
// SessionManager mints and verifies stateless session tokens.
|
||||
type SessionManager interface {
|
||||
Issue(userID string, ttl time.Duration) (string, error)
|
||||
Verify(token string) (userID string, err error)
|
||||
}
|
||||
|
||||
// PasswordHasher hashes/compares PINs (bcrypt in prod).
|
||||
type PasswordHasher interface {
|
||||
Hash(plain string) (string, error)
|
||||
Compare(hash, plain string) bool
|
||||
}
|
||||
|
||||
// ClickRecorder accepts clicks off the hot path; implementations batch them.
|
||||
type ClickRecorder interface {
|
||||
Record(code string)
|
||||
}
|
||||
@@ -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