mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
256 lines
6.1 KiB
Go
256 lines
6.1 KiB
Go
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)
|
|
}
|