package main import ( "crypto/sha256" "encoding/base64" "fmt" "net/http" "strings" "time" ) // ShortURL represents a shortened URL mapping type ShortURL struct { Code string `json:"code"` OriginalURL string `json:"original_url"` ItemID *int64 `json:"item_id,omitempty"` FeedURL string `json:"feed_url,omitempty"` CreatedAt time.Time `json:"created_at"` ClickCount int `json:"click_count"` } // Click represents a click event on a short URL type Click struct { ID int64 `json:"id"` ShortCode string `json:"short_code"` ClickedAt time.Time `json:"clicked_at"` Referrer string `json:"referrer,omitempty"` UserAgent string `json:"user_agent,omitempty"` IPHash string `json:"ip_hash,omitempty"` Country string `json:"country,omitempty"` } // Base62 alphabet for short codes const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" // generateShortCode creates a short code from a URL // Uses first 6 characters of base64-encoded SHA256 hash func generateShortCode(url string) string { hash := sha256.Sum256([]byte(url)) encoded := base64.URLEncoding.EncodeToString(hash[:]) // Take first 6 chars, replace URL-unsafe chars code := encoded[:6] code = strings.ReplaceAll(code, "-", "x") code = strings.ReplaceAll(code, "_", "y") return code } // CreateShortURL creates or retrieves a short URL for the given original URL func (c *Crawler) CreateShortURL(originalURL string, itemID *int64, feedURL string) (*ShortURL, error) { // Check if we already have this URL var existing ShortURL err := c.db.QueryRow(` SELECT code, original_url, item_id, feed_url, created_at, click_count FROM short_urls WHERE original_url = $1 `, originalURL).Scan(&existing.Code, &existing.OriginalURL, &existing.ItemID, &existing.FeedURL, &existing.CreatedAt, &existing.ClickCount) if err == nil { return &existing, nil } // Generate new short code code := generateShortCode(originalURL) // Handle collision by appending counter baseCode := code for i := 0; i < 100; i++ { if i > 0 { code = fmt.Sprintf("%s%d", baseCode, i) } var existingURL string err := c.db.QueryRow("SELECT original_url FROM short_urls WHERE code = $1", code).Scan(&existingURL) if err != nil { // Code doesn't exist, use it break } if existingURL == originalURL { // Same URL, return existing return c.GetShortURL(code) } // Collision with different URL, try next } // Insert new short URL now := time.Now() _, err = c.db.Exec(` INSERT INTO short_urls (code, original_url, item_id, feed_url, created_at, click_count) VALUES ($1, $2, $3, $4, $5, 0) `, code, originalURL, itemID, NullableString(feedURL), now) if err != nil { return nil, fmt.Errorf("failed to create short URL: %v", err) } return &ShortURL{ Code: code, OriginalURL: originalURL, ItemID: itemID, FeedURL: feedURL, CreatedAt: now, ClickCount: 0, }, nil } // GetShortURL retrieves a short URL by code func (c *Crawler) GetShortURL(code string) (*ShortURL, error) { var s ShortURL var itemID *int64 var feedURL *string err := c.db.QueryRow(` SELECT code, original_url, item_id, feed_url, created_at, click_count FROM short_urls WHERE code = $1 `, code).Scan(&s.Code, &s.OriginalURL, &itemID, &feedURL, &s.CreatedAt, &s.ClickCount) if err != nil { return nil, err } s.ItemID = itemID s.FeedURL = StringValue(feedURL) return &s, nil } // RecordClick records a click on a short URL func (c *Crawler) RecordClick(code string, r *http.Request) error { // Hash the IP for privacy ip := r.RemoteAddr if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { ip = strings.Split(forwarded, ",")[0] } ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))[:16] referrer := r.Header.Get("Referer") userAgent := r.Header.Get("User-Agent") // Truncate long values if len(referrer) > 500 { referrer = referrer[:500] } if len(userAgent) > 500 { userAgent = userAgent[:500] } // Insert click record _, err := c.db.Exec(` INSERT INTO clicks (short_code, clicked_at, referrer, user_agent, ip_hash) VALUES ($1, $2, $3, $4, $5) `, code, time.Now(), NullableString(referrer), NullableString(userAgent), ipHash) if err != nil { return err } // Increment click count _, err = c.db.Exec(` UPDATE short_urls SET click_count = click_count + 1 WHERE code = $1 `, code) return err } // GetShortURLForPost returns the short URL string for use in posts // Format: https://url.1440.news/{code} func (c *Crawler) GetShortURLForPost(originalURL string, itemID *int64, feedURL string) (string, error) { shortURL, err := c.CreateShortURL(originalURL, itemID, feedURL) if err != nil { return "", err } return fmt.Sprintf("https://url.1440.news/%s", shortURL.Code), nil } // GetClickStats returns click statistics for a short URL func (c *Crawler) GetClickStats(code string) (int, error) { var count int err := c.db.QueryRow("SELECT click_count FROM short_urls WHERE code = $1", code).Scan(&count) return count, err } // GetRecentClicks returns recent clicks across all short URLs func (c *Crawler) GetRecentClicks(limit int) ([]Click, error) { rows, err := c.db.Query(` SELECT id, short_code, clicked_at, referrer, user_agent, ip_hash, country FROM clicks ORDER BY clicked_at DESC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() var clicks []Click for rows.Next() { var click Click var referrer, userAgent, ipHash, country *string err := rows.Scan(&click.ID, &click.ShortCode, &click.ClickedAt, &referrer, &userAgent, &ipHash, &country) if err != nil { continue } click.Referrer = StringValue(referrer) click.UserAgent = StringValue(userAgent) click.IPHash = StringValue(ipHash) click.Country = StringValue(country) clicks = append(clicks, click) } return clicks, nil } // GetTopShortURLs returns the most clicked short URLs func (c *Crawler) GetTopShortURLs(limit int) ([]ShortURL, error) { rows, err := c.db.Query(` SELECT code, original_url, item_id, feed_url, created_at, click_count FROM short_urls ORDER BY click_count DESC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() var urls []ShortURL for rows.Next() { var s ShortURL var itemID *int64 var feedURL *string err := rows.Scan(&s.Code, &s.OriginalURL, &itemID, &feedURL, &s.CreatedAt, &s.ClickCount) if err != nil { continue } s.ItemID = itemID s.FeedURL = StringValue(feedURL) urls = append(urls, s) } return urls, nil }