webapi_vanity.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. package state
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "log/slog"
  7. "regexp"
  8. "strings"
  9. "time"
  10. )
  11. // VanityURL represents a user's vanity URL configuration.
  12. type VanityURL struct {
  13. ScreenName string `json:"screenName"`
  14. VanityURL string `json:"vanityUrl"`
  15. DisplayName string `json:"displayName,omitempty"`
  16. Bio string `json:"bio,omitempty"`
  17. Location string `json:"location,omitempty"`
  18. Website string `json:"website,omitempty"`
  19. CreatedAt time.Time `json:"createdAt"`
  20. UpdatedAt time.Time `json:"updatedAt"`
  21. IsActive bool `json:"isActive"`
  22. ClickCount int `json:"clickCount"`
  23. LastAccessed *time.Time `json:"lastAccessed,omitempty"`
  24. }
  25. // VanityURLRedirect represents a vanity URL access record.
  26. type VanityURLRedirect struct {
  27. ID int64 `json:"id"`
  28. VanityURL string `json:"vanityUrl"`
  29. AccessedAt time.Time `json:"accessedAt"`
  30. IPAddress string `json:"ipAddress,omitempty"`
  31. UserAgent string `json:"userAgent,omitempty"`
  32. Referer string `json:"referer,omitempty"`
  33. }
  34. // VanityInfo represents the response for vanity URL lookups.
  35. type VanityInfo struct {
  36. ScreenName string `json:"screenName"`
  37. VanityURL string `json:"vanityUrl"`
  38. DisplayName string `json:"displayName,omitempty"`
  39. Bio string `json:"bio,omitempty"`
  40. Location string `json:"location,omitempty"`
  41. Website string `json:"website,omitempty"`
  42. ProfileURL string `json:"profileUrl"`
  43. IsActive bool `json:"isActive"`
  44. Extra map[string]interface{} `json:"extra,omitempty"`
  45. }
  46. // VanityURLManager manages vanity URL operations.
  47. type VanityURLManager struct {
  48. db *sql.DB
  49. logger *slog.Logger
  50. baseURL string // Base URL for the service (e.g., "https://aim.example.com")
  51. reserved []string // Reserved URLs that cannot be claimed
  52. }
  53. // NewVanityURLManager creates a new vanity URL manager.
  54. func NewVanityURLManager(db *sql.DB, logger *slog.Logger, baseURL string) *VanityURLManager {
  55. return &VanityURLManager{
  56. db: db,
  57. logger: logger,
  58. baseURL: baseURL,
  59. reserved: []string{
  60. "api", "admin", "help", "support", "about", "terms", "privacy",
  61. "login", "logout", "register", "signup", "signin", "settings",
  62. "profile", "user", "users", "aim", "aol", "webapi", "oscar",
  63. "chat", "im", "message", "buddy", "buddies", "feed", "rss",
  64. },
  65. }
  66. }
  67. // CreateOrUpdateVanityURL creates or updates a vanity URL for a user.
  68. func (m *VanityURLManager) CreateOrUpdateVanityURL(ctx context.Context, screenName string, vanityURL string, info map[string]interface{}) error {
  69. // Validate vanity URL
  70. if err := m.validateVanityURL(vanityURL); err != nil {
  71. return err
  72. }
  73. // Check if URL is reserved
  74. if m.isReserved(vanityURL) {
  75. return fmt.Errorf("vanity URL '%s' is reserved", vanityURL)
  76. }
  77. // Extract optional fields from info
  78. displayName, _ := info["displayName"].(string)
  79. bio, _ := info["bio"].(string)
  80. location, _ := info["location"].(string)
  81. website, _ := info["website"].(string)
  82. now := time.Now()
  83. // Try to update existing record first
  84. updateQuery := `
  85. UPDATE vanity_urls
  86. SET vanity_url = ?, display_name = ?, bio = ?, location = ?,
  87. website = ?, updated_at = ?, is_active = ?
  88. WHERE screen_name = ?
  89. `
  90. result, err := m.db.ExecContext(ctx, updateQuery,
  91. vanityURL, displayName, bio, location, website,
  92. now.Unix(), true, screenName,
  93. )
  94. if err != nil {
  95. return fmt.Errorf("failed to update vanity URL: %w", err)
  96. }
  97. rowsAffected, _ := result.RowsAffected()
  98. if rowsAffected > 0 {
  99. m.logger.InfoContext(ctx, "updated vanity URL",
  100. "screenName", screenName,
  101. "vanityURL", vanityURL,
  102. )
  103. return nil
  104. }
  105. // Insert new record
  106. insertQuery := `
  107. INSERT INTO vanity_urls (
  108. screen_name, vanity_url, display_name, bio, location,
  109. website, created_at, updated_at, is_active, click_count
  110. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  111. `
  112. _, err = m.db.ExecContext(ctx, insertQuery,
  113. screenName, vanityURL, displayName, bio, location,
  114. website, now.Unix(), now.Unix(), true, 0,
  115. )
  116. if err != nil {
  117. if strings.Contains(err.Error(), "UNIQUE") {
  118. return fmt.Errorf("vanity URL '%s' is already taken", vanityURL)
  119. }
  120. return fmt.Errorf("failed to create vanity URL: %w", err)
  121. }
  122. m.logger.InfoContext(ctx, "created vanity URL",
  123. "screenName", screenName,
  124. "vanityURL", vanityURL,
  125. )
  126. return nil
  127. }
  128. // GetVanityInfo retrieves vanity URL information.
  129. func (m *VanityURLManager) GetVanityInfo(ctx context.Context, vanityURL string) (*VanityInfo, error) {
  130. // Clean the vanity URL
  131. vanityURL = strings.ToLower(strings.TrimSpace(vanityURL))
  132. query := `
  133. SELECT screen_name, vanity_url, display_name, bio, location,
  134. website, created_at, updated_at, is_active, click_count, last_accessed
  135. FROM vanity_urls
  136. WHERE vanity_url = ? AND is_active = 1
  137. `
  138. var v VanityURL
  139. var createdAt, updatedAt int64
  140. var lastAccessed sql.NullInt64
  141. err := m.db.QueryRowContext(ctx, query, vanityURL).Scan(
  142. &v.ScreenName, &v.VanityURL, &v.DisplayName, &v.Bio, &v.Location,
  143. &v.Website, &createdAt, &updatedAt, &v.IsActive, &v.ClickCount, &lastAccessed,
  144. )
  145. if err == sql.ErrNoRows {
  146. return nil, fmt.Errorf("vanity URL not found: %s", vanityURL)
  147. }
  148. if err != nil {
  149. return nil, fmt.Errorf("failed to get vanity info: %w", err)
  150. }
  151. v.CreatedAt = time.Unix(createdAt, 0)
  152. v.UpdatedAt = time.Unix(updatedAt, 0)
  153. if lastAccessed.Valid {
  154. t := time.Unix(lastAccessed.Int64, 0)
  155. v.LastAccessed = &t
  156. }
  157. // Create response info
  158. info := &VanityInfo{
  159. ScreenName: v.ScreenName,
  160. VanityURL: v.VanityURL,
  161. DisplayName: v.DisplayName,
  162. Bio: v.Bio,
  163. Location: v.Location,
  164. Website: v.Website,
  165. ProfileURL: m.buildProfileURL(v.VanityURL),
  166. IsActive: v.IsActive,
  167. Extra: map[string]interface{}{
  168. "createdAt": v.CreatedAt.Unix(),
  169. "clickCount": v.ClickCount,
  170. },
  171. }
  172. // Update click count and last accessed asynchronously
  173. go m.recordAccess(context.Background(), vanityURL)
  174. return info, nil
  175. }
  176. // GetVanityInfoByScreenName retrieves vanity URL info by screen name.
  177. func (m *VanityURLManager) GetVanityInfoByScreenName(ctx context.Context, screenName string) (*VanityInfo, error) {
  178. query := `
  179. SELECT screen_name, vanity_url, display_name, bio, location,
  180. website, created_at, updated_at, is_active, click_count, last_accessed
  181. FROM vanity_urls
  182. WHERE screen_name = ? AND is_active = 1
  183. `
  184. var v VanityURL
  185. var createdAt, updatedAt int64
  186. var lastAccessed sql.NullInt64
  187. err := m.db.QueryRowContext(ctx, query, screenName).Scan(
  188. &v.ScreenName, &v.VanityURL, &v.DisplayName, &v.Bio, &v.Location,
  189. &v.Website, &createdAt, &updatedAt, &v.IsActive, &v.ClickCount, &lastAccessed,
  190. )
  191. if err == sql.ErrNoRows {
  192. return nil, nil // No vanity URL configured
  193. }
  194. if err != nil {
  195. return nil, fmt.Errorf("failed to get vanity info: %w", err)
  196. }
  197. v.CreatedAt = time.Unix(createdAt, 0)
  198. v.UpdatedAt = time.Unix(updatedAt, 0)
  199. if lastAccessed.Valid {
  200. t := time.Unix(lastAccessed.Int64, 0)
  201. v.LastAccessed = &t
  202. }
  203. // Create response info
  204. info := &VanityInfo{
  205. ScreenName: v.ScreenName,
  206. VanityURL: v.VanityURL,
  207. DisplayName: v.DisplayName,
  208. Bio: v.Bio,
  209. Location: v.Location,
  210. Website: v.Website,
  211. ProfileURL: m.buildProfileURL(v.VanityURL),
  212. IsActive: v.IsActive,
  213. Extra: map[string]interface{}{
  214. "createdAt": v.CreatedAt.Unix(),
  215. "clickCount": v.ClickCount,
  216. },
  217. }
  218. return info, nil
  219. }
  220. // DeleteVanityURL removes a user's vanity URL.
  221. func (m *VanityURLManager) DeleteVanityURL(ctx context.Context, screenName string) error {
  222. query := `UPDATE vanity_urls SET is_active = 0, updated_at = ? WHERE screen_name = ?`
  223. _, err := m.db.ExecContext(ctx, query, time.Now().Unix(), screenName)
  224. if err != nil {
  225. return fmt.Errorf("failed to delete vanity URL: %w", err)
  226. }
  227. m.logger.InfoContext(ctx, "deleted vanity URL", "screenName", screenName)
  228. return nil
  229. }
  230. // CheckAvailability checks if a vanity URL is available.
  231. func (m *VanityURLManager) CheckAvailability(ctx context.Context, vanityURL string) (bool, error) {
  232. // Validate format
  233. if err := m.validateVanityURL(vanityURL); err != nil {
  234. return false, err
  235. }
  236. // Check if reserved
  237. if m.isReserved(vanityURL) {
  238. return false, nil
  239. }
  240. // Check database
  241. query := `SELECT COUNT(*) FROM vanity_urls WHERE vanity_url = ? AND is_active = 1`
  242. var count int
  243. err := m.db.QueryRowContext(ctx, query, vanityURL).Scan(&count)
  244. if err != nil {
  245. return false, fmt.Errorf("failed to check availability: %w", err)
  246. }
  247. return count == 0, nil
  248. }
  249. // GetPopularVanityURLs retrieves the most accessed vanity URLs.
  250. func (m *VanityURLManager) GetPopularVanityURLs(ctx context.Context, limit int) ([]VanityInfo, error) {
  251. query := `
  252. SELECT screen_name, vanity_url, display_name, bio, location,
  253. website, is_active, click_count
  254. FROM vanity_urls
  255. WHERE is_active = 1
  256. ORDER BY click_count DESC
  257. LIMIT ?
  258. `
  259. rows, err := m.db.QueryContext(ctx, query, limit)
  260. if err != nil {
  261. return nil, fmt.Errorf("failed to get popular vanity URLs: %w", err)
  262. }
  263. defer rows.Close()
  264. var results []VanityInfo
  265. for rows.Next() {
  266. var info VanityInfo
  267. var displayName, bio, location, website sql.NullString
  268. err := rows.Scan(
  269. &info.ScreenName, &info.VanityURL, &displayName, &bio,
  270. &location, &website, &info.IsActive, &info.Extra,
  271. )
  272. if err != nil {
  273. return nil, fmt.Errorf("failed to scan vanity info: %w", err)
  274. }
  275. if displayName.Valid {
  276. info.DisplayName = displayName.String
  277. }
  278. if bio.Valid {
  279. info.Bio = bio.String
  280. }
  281. if location.Valid {
  282. info.Location = location.String
  283. }
  284. if website.Valid {
  285. info.Website = website.String
  286. }
  287. info.ProfileURL = m.buildProfileURL(info.VanityURL)
  288. results = append(results, info)
  289. }
  290. return results, nil
  291. }
  292. // recordAccess records a vanity URL access.
  293. func (m *VanityURLManager) recordAccess(ctx context.Context, vanityURL string) {
  294. // Update click count and last accessed time
  295. updateQuery := `
  296. UPDATE vanity_urls
  297. SET click_count = click_count + 1, last_accessed = ?
  298. WHERE vanity_url = ?
  299. `
  300. _, err := m.db.ExecContext(ctx, updateQuery, time.Now().Unix(), vanityURL)
  301. if err != nil {
  302. m.logger.Error("failed to record vanity URL access", "error", err, "vanityURL", vanityURL)
  303. }
  304. }
  305. // LogRedirect logs a vanity URL redirect for analytics.
  306. func (m *VanityURLManager) LogRedirect(ctx context.Context, redirect VanityURLRedirect) error {
  307. query := `
  308. INSERT INTO vanity_url_redirects (vanity_url, accessed_at, ip_address, user_agent, referer)
  309. VALUES (?, ?, ?, ?, ?)
  310. `
  311. _, err := m.db.ExecContext(ctx, query,
  312. redirect.VanityURL, redirect.AccessedAt.Unix(),
  313. redirect.IPAddress, redirect.UserAgent, redirect.Referer,
  314. )
  315. if err != nil {
  316. return fmt.Errorf("failed to log redirect: %w", err)
  317. }
  318. return nil
  319. }
  320. // validateVanityURL validates the format of a vanity URL.
  321. func (m *VanityURLManager) validateVanityURL(vanityURL string) error {
  322. // Clean and lowercase
  323. vanityURL = strings.ToLower(strings.TrimSpace(vanityURL))
  324. // Check length
  325. if len(vanityURL) < 3 || len(vanityURL) > 30 {
  326. return fmt.Errorf("vanity URL must be between 3 and 30 characters")
  327. }
  328. // Check format (alphanumeric, hyphens, underscores only)
  329. validFormat := regexp.MustCompile(`^[a-z0-9_-]+$`)
  330. if !validFormat.MatchString(vanityURL) {
  331. return fmt.Errorf("vanity URL can only contain letters, numbers, hyphens, and underscores")
  332. }
  333. // Can't start or end with special characters
  334. if strings.HasPrefix(vanityURL, "-") || strings.HasPrefix(vanityURL, "_") ||
  335. strings.HasSuffix(vanityURL, "-") || strings.HasSuffix(vanityURL, "_") {
  336. return fmt.Errorf("vanity URL cannot start or end with hyphens or underscores")
  337. }
  338. return nil
  339. }
  340. // isReserved checks if a vanity URL is in the reserved list.
  341. func (m *VanityURLManager) isReserved(vanityURL string) bool {
  342. vanityURL = strings.ToLower(vanityURL)
  343. for _, reserved := range m.reserved {
  344. if vanityURL == reserved {
  345. return true
  346. }
  347. }
  348. return false
  349. }
  350. // buildProfileURL builds the full profile URL for a vanity URL.
  351. func (m *VanityURLManager) buildProfileURL(vanityURL string) string {
  352. if m.baseURL == "" {
  353. return fmt.Sprintf("/profile/%s", vanityURL)
  354. }
  355. return fmt.Sprintf("%s/profile/%s", strings.TrimRight(m.baseURL, "/"), vanityURL)
  356. }