package models
import (
"crypto/rand"
"errors"
"fmt"
"html"
"html/template"
"math/big"
"net/url"
"strings"
"time"
"theskyscape.com/repo/skykit"
)
type Link struct {
skykit.Model
ShortCode string
TargetURL string
UserID string
}
func (l *Link) GetModel() *skykit.Model {
return &l.Model
}
func (l *Link) DisplayURL() template.HTML {
return template.HTML(html.EscapeString(l.TargetURL))
}
var pacific = mustLoadLocation()
func mustLoadLocation() *time.Location {
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
return time.FixedZone("PST", -8*3600)
}
return loc
}
func (l *Link) CreatedAtPST() string {
return l.CreatedAt.In(pacific).Format("Jan 2, 2006 3:04 PM")
}
var (
userLookup func(id string) (*skykit.User, error)
anonUser = &skykit.User{
Handle: "anon",
Avatar: "https://robots.skysca.pe/anon",
}
)
// SetUserLookup wires a lookup function for link ownership metadata.
func SetUserLookup(fn func(id string) (*skykit.User, error)) {
userLookup = fn
}
// User returns the creator of the link or an anonymous user placeholder.
func (l *Link) User() *skykit.User {
if l.UserID == "" {
return anonUser
}
if userLookup == nil {
return anonUser
}
user, err := userLookup(l.UserID)
if err != nil || user == nil {
return anonUser
}
return user
}
func (l *Link) CanDelete(user *skykit.User) bool {
if l.UserID == "" {
return true
}
if user == nil {
return false
}
return l.UserID == user.ID
}
func CreateLink(targetURL, customShort string, user *skykit.User) (*Link, error) {
targetURL = strings.TrimSpace(targetURL)
if targetURL == "" {
return nil, errors.New("target URL is required")
}
parsed, err := url.ParseRequestURI(targetURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("enter a valid URL including http or https")
}
short, err := sanitizeShortCode(customShort)
if err != nil {
return nil, err
}
if short == "" {
short, err = generateAvailableShortCode()
if err != nil {
return nil, err
}
} else if err := ensureShortCodeAvailable(short); err != nil {
return nil, err
}
link := Links.New()
link.ShortCode = short
link.TargetURL = targetURL
if user != nil {
link.UserID = user.ID
}
if _, err := Links.Insert(link); err != nil {
return nil, fmt.Errorf("unable to save link: %w", err)
}
return link, nil
}
func sanitizeShortCode(code string) (string, error) {
code = strings.ToLower(strings.TrimSpace(code))
builder := strings.Builder{}
for _, r := range code {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '-' || r == '_':
builder.WriteRune(r)
default:
return "", errors.New("short codes may use letters, numbers, dashes, and underscores")
}
}
return builder.String(), nil
}
func generateAvailableShortCode() (string, error) {
for i := 0; i < 5; i++ {
code, err := randomShortCode(6)
if err != nil {
return "", errors.New("failed generating short code")
}
if err := ensureShortCodeAvailable(code); err == nil {
return code, nil
}
}
return "", errors.New("unable to generate unique short code, please provide one manually")
}
func ensureShortCodeAvailable(code string) error {
_, err := Links.First("WHERE ShortCode = ?", code)
if err == nil {
return errors.New("short code already taken")
}
if errors.Is(err, skykit.ErrNotFound) {
return nil
}
return fmt.Errorf("failed to check short code: %w", err)
}
func randomShortCode(length int) (string, error) {
const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"
result := make([]byte, length)
max := big.NewInt(int64(len(alphabet)))
for i := range result {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
result[i] = alphabet[n.Int64()]
}
return string(result), nil
}