Przeglądaj źródła

webapi: remove dead code

Mike 2 tygodni temu
rodzic
commit
2416e01ff7

+ 0 - 27
server/webapi/handlers/amf_encoder.go

@@ -189,11 +189,6 @@ func (e *AMFEncoder) sanitizeForAMF3(data interface{}) interface{} {
 	}
 	}
 }
 }
 
 
-// toAMFCompatible converts Go types to AMF3-compatible types
-func (e *AMFEncoder) toAMFCompatible(data interface{}) interface{} {
-	return e.toAMF3Compatible(data)
-}
-
 // baseResponseToMap converts BaseResponse to AMF3-compatible map
 // baseResponseToMap converts BaseResponse to AMF3-compatible map
 func (e *AMFEncoder) baseResponseToMap(resp BaseResponse) map[string]interface{} {
 func (e *AMFEncoder) baseResponseToMap(resp BaseResponse) map[string]interface{} {
 	return map[string]interface{}{
 	return map[string]interface{}{
@@ -405,25 +400,3 @@ func DetectAMFVersion(r *http.Request) AMFVersion {
 	// Default to AMF3 for modern clients
 	// Default to AMF3 for modern clients
 	return AMF3
 	return AMF3
 }
 }
-
-// IsAMFRequest checks if the request is asking for AMF format
-func IsAMFRequest(r *http.Request) bool {
-	if r == nil {
-		return false
-	}
-
-	// Check query parameter
-	format := strings.ToLower(r.URL.Query().Get("f"))
-	if format == "amf" || format == "amf0" || format == "amf3" {
-		return true
-	}
-
-	// Check Accept header
-	accept := strings.ToLower(r.Header.Get("Accept"))
-	if strings.Contains(accept, "application/x-amf") ||
-		strings.Contains(accept, "application/amf") {
-		return true
-	}
-
-	return false
-}

+ 3 - 55
server/webapi/handlers/amf_encoder_test.go

@@ -195,58 +195,6 @@ func TestDetectAMFVersion(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestIsAMFRequest(t *testing.T) {
-	tests := []struct {
-		name     string
-		request  *http.Request
-		expected bool
-	}{
-		{
-			name:     "Query parameter amf",
-			request:  httptest.NewRequest("GET", "/?f=amf", nil),
-			expected: true,
-		},
-		{
-			name:     "Query parameter amf3",
-			request:  httptest.NewRequest("GET", "/?f=amf3", nil),
-			expected: true,
-		},
-		{
-			name: "Accept header",
-			request: func() *http.Request {
-				req := httptest.NewRequest("GET", "/", nil)
-				req.Header.Set("Accept", "application/x-amf")
-				return req
-			}(),
-			expected: true,
-		},
-		{
-			name:     "JSON format",
-			request:  httptest.NewRequest("GET", "/?f=json", nil),
-			expected: false,
-		},
-		{
-			name:     "No format",
-			request:  httptest.NewRequest("GET", "/", nil),
-			expected: false,
-		},
-		{
-			name:     "Nil request",
-			request:  nil,
-			expected: false,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			result := IsAMFRequest(tt.request)
-			if result != tt.expected {
-				t.Errorf("IsAMFRequest() = %v, want %v", result, tt.expected)
-			}
-		})
-	}
-}
-
 func TestSendAMF(t *testing.T) {
 func TestSendAMF(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name         string
 		name         string
@@ -335,7 +283,7 @@ func TestStructToMap(t *testing.T) {
 		NoTag:    "should appear with field name",
 		NoTag:    "should appear with field name",
 	}
 	}
 
 
-	result := encoder.toAMFCompatible(testStruct)
+	result := encoder.toAMF3Compatible(testStruct)
 	resultMap, ok := result.(map[string]interface{})
 	resultMap, ok := result.(map[string]interface{})
 	if !ok {
 	if !ok {
 		t.Fatal("Expected map[string]interface{}")
 		t.Fatal("Expected map[string]interface{}")
@@ -375,7 +323,7 @@ func TestSliceToArray(t *testing.T) {
 		map[string]interface{}{"nested": "value"},
 		map[string]interface{}{"nested": "value"},
 	}
 	}
 
 
-	result := encoder.toAMFCompatible(input)
+	result := encoder.toAMF3Compatible(input)
 	resultArray, ok := result.([]interface{})
 	resultArray, ok := result.([]interface{})
 	if !ok {
 	if !ok {
 		t.Fatal("Expected []interface{}")
 		t.Fatal("Expected []interface{}")
@@ -459,7 +407,7 @@ func TestZeroValueDetection(t *testing.T) {
 		TrueValue:   true,
 		TrueValue:   true,
 	}
 	}
 
 
-	result := encoder.toAMFCompatible(testStruct)
+	result := encoder.toAMF3Compatible(testStruct)
 	resultMap, ok := result.(map[string]interface{})
 	resultMap, ok := result.(map[string]interface{})
 	if !ok {
 	if !ok {
 		t.Fatal("Expected map[string]interface{}")
 		t.Fatal("Expected map[string]interface{}")

+ 0 - 352
server/webapi/handlers/buddyfeed.go

@@ -1,352 +0,0 @@
-package handlers
-
-import (
-	"encoding/xml"
-	"fmt"
-	"log/slog"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// BuddyFeedHandler handles Web AIM API buddy feed endpoints.
-type BuddyFeedHandler struct {
-	SessionManager *state.WebAPISessionManager
-	FeedManager    *state.BuddyFeedManager
-	Logger         *slog.Logger
-}
-
-// GetUser handles GET /buddyfeed/getUser requests to retrieve a user's feed.
-func (h *BuddyFeedHandler) GetUser(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Get target user from 't' parameter as per spec
-	targetUser := r.URL.Query().Get("t")
-	if targetUser == "" {
-		SendError(w, http.StatusBadRequest, "missing 't' parameter")
-		return
-	}
-
-	// Get format parameter
-	format := strings.ToLower(r.URL.Query().Get("f"))
-
-	h.Logger.DebugContext(ctx, "retrieving user feed",
-		"user", targetUser,
-		"format", format,
-	)
-
-	// Get the feed configuration
-	feed, err := h.FeedManager.GetUserFeed(ctx, targetUser)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to get user feed",
-			"user", targetUser,
-			"error", err,
-		)
-		SendError(w, http.StatusInternalServerError, "failed to retrieve feed")
-		return
-	}
-
-	// Build feed response
-	var feedResponse *FeedResponse
-	if feed == nil {
-		// No feed configured - generate empty feed
-		feedResponse = GenerateEmptyFeed(targetUser)
-	} else {
-		// Get feed items
-		items, err := h.FeedManager.GetFeedItems(ctx, feed.ID, 50)
-		if err != nil {
-			h.Logger.ErrorContext(ctx, "failed to get feed items",
-				"feedID", feed.ID,
-				"error", err,
-			)
-			SendError(w, http.StatusInternalServerError, "failed to retrieve feed items")
-			return
-		}
-
-		feedResponse = &FeedResponse{
-			Feed:  *feed,
-			Items: items,
-		}
-	}
-
-	// Send response based on format
-	switch format {
-	case "atom":
-		h.sendAtomFeed(w, feedResponse.ToAtom())
-	case "json":
-		response := BaseResponse{}
-		response.Response.StatusCode = 200
-		response.Response.StatusText = "OK"
-		response.Response.Data = feedResponse.ToJSON()
-		SendResponse(w, r, response, h.Logger)
-	case "rss", "native", "":
-		h.sendRSSFeed(w, feedResponse.ToRSS())
-	default:
-		h.sendRSSFeed(w, feedResponse.ToRSS())
-	}
-}
-
-// GetBuddylist handles GET /buddyfeed/getBuddylist requests to retrieve aggregated buddy feeds.
-func (h *BuddyFeedHandler) GetBuddylist(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Authentication required for buddy list feed
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		SendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		SendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
-	// Get format and limit parameters
-	format := strings.ToLower(r.URL.Query().Get("f"))
-	if format == "" {
-		format = "rss"
-	}
-
-	limit := 100 // Default limit
-	if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
-		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
-			limit = l
-			if limit > 500 {
-				limit = 500 // Max limit
-			}
-		}
-	}
-
-	h.Logger.DebugContext(ctx, "retrieving buddy list feed",
-		"screenName", session.ScreenName.String(),
-		"format", format,
-		"limit", limit,
-	)
-
-	// Get buddy list from OSCAR session if available
-	var buddies []state.IdentScreenName
-
-	// Return empty feed for now - buddy list integration pending
-	if len(buddies) == 0 {
-		h.Logger.InfoContext(ctx, "no buddies found for feed aggregation",
-			"screenName", session.ScreenName.String(),
-		)
-
-		// Return empty feed
-		emptyFeed := map[string]interface{}{
-			"title":       fmt.Sprintf("%s's Buddy Feed", session.ScreenName.String()),
-			"description": "Aggregated feed from your buddy list",
-			"items":       []interface{}{},
-		}
-
-		if format == "json" {
-			response := BaseResponse{}
-			response.Response.StatusCode = 200
-			response.Response.StatusText = "OK"
-			response.Response.Data = emptyFeed
-			SendResponse(w, r, response, h.Logger)
-		} else {
-			h.sendEmptyRSSFeed(w, session.ScreenName.String())
-		}
-		return
-	}
-
-	// Get aggregated feed items
-	items, err := h.FeedManager.GetBuddyListFeedItems(ctx, buddies, limit)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to get buddy list feed",
-			"screenName", session.ScreenName.String(),
-			"error", err,
-		)
-		SendError(w, http.StatusInternalServerError, "failed to retrieve feed")
-		return
-	}
-
-	// Build aggregated feed response
-	feedResponse := &FeedResponse{
-		Feed: state.BuddyFeed{
-			Title:       "Buddy List Feed",
-			Description: "Aggregated feed from your buddy list",
-			Link:        "/buddyfeed/getBuddylist",
-			PublishedAt: time.Now(),
-			UpdatedAt:   time.Now(),
-		},
-		Items: items,
-	}
-
-	// Send response based on format
-	switch format {
-	case "atom":
-		h.sendAtomFeed(w, feedResponse.ToAtom())
-	case "json":
-		response := BaseResponse{}
-		response.Response.StatusCode = 200
-		response.Response.StatusText = "OK"
-		response.Response.Data = feedResponse.ToJSON()
-		SendResponse(w, r, response, h.Logger)
-	case "rss", "":
-		h.sendRSSFeed(w, feedResponse.ToRSS())
-	default:
-		h.sendRSSFeed(w, feedResponse.ToRSS())
-	}
-}
-
-// PushFeed handles GET /buddyfeed/pushFeed requests to submit feed updates.
-func (h *BuddyFeedHandler) PushFeed(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Get authentication token or session
-	token := r.URL.Query().Get("a")
-	aimsid := r.URL.Query().Get("aimsid")
-
-	if token == "" && aimsid == "" {
-		SendError(w, http.StatusBadRequest, "authentication required")
-		return
-	}
-
-	var screenName string
-	if aimsid != "" {
-		session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-		if err != nil {
-			SendError(w, http.StatusUnauthorized, "invalid or expired session")
-			return
-		}
-		screenName = session.ScreenName.String()
-
-		if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-			h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-		}
-	} else {
-		// Extract screen name from token authentication
-		screenName = r.URL.Query().Get("s")
-		if screenName == "" {
-			SendError(w, http.StatusBadRequest, "missing source user")
-			return
-		}
-	}
-
-	// Extract feed parameters as per spec
-	feedTitle := r.URL.Query().Get("feedTitle")
-	feedLink := r.URL.Query().Get("feedLink")
-	feedDesc := r.URL.Query().Get("feedDesc")
-	itemTitle := r.URL.Query().Get("itemTitle")
-	itemLink := r.URL.Query().Get("itemLink")
-	itemGuid := r.URL.Query().Get("itemGuid")
-
-	// Validate required parameters
-	if feedTitle == "" || feedLink == "" || feedDesc == "" ||
-		itemTitle == "" || itemLink == "" || itemGuid == "" {
-		SendError(w, http.StatusBadRequest, "missing required feed parameters")
-		return
-	}
-
-	h.Logger.InfoContext(ctx, "pushing feed update",
-		"screenName", screenName,
-		"itemTitle", itemTitle,
-	)
-
-	// Get or create feed for user
-	feedID, err := h.FeedManager.GetOrCreateFeedForUser(ctx, screenName, "status")
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to get/create feed",
-			"screenName", screenName,
-			"error", err,
-		)
-		SendError(w, http.StatusInternalServerError, "failed to get/create feed")
-		return
-	}
-
-	// Build feed item
-	item := state.BuddyFeedItem{
-		Title:       itemTitle,
-		Description: r.URL.Query().Get("itemDesc"),
-		Link:        itemLink,
-		GUID:        itemGuid,
-		Author:      screenName,
-		PublishedAt: time.Now(),
-	}
-
-	// Add category if provided
-	if category := r.URL.Query().Get("itemCategory"); category != "" {
-		item.Categories = []string{category}
-	}
-
-	// Add the feed item
-	if _, err := h.FeedManager.AddFeedItem(ctx, feedID, item); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to add feed item",
-			"screenName", screenName,
-			"feedID", feedID,
-			"error", err,
-		)
-		SendError(w, http.StatusInternalServerError, "failed to add feed item")
-		return
-	}
-
-	// Send success response
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = map[string]interface{}{
-		"success": true,
-	}
-
-	SendResponse(w, r, response, h.Logger)
-}
-
-// sendRSSFeed sends an RSS feed response.
-func (h *BuddyFeedHandler) sendRSSFeed(w http.ResponseWriter, feed *RSSFeed) {
-	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
-
-	// Add XML declaration
-	_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
-
-	// Marshal and write the feed
-	encoder := xml.NewEncoder(w)
-	encoder.Indent("", "  ")
-	if err := encoder.Encode(feed); err != nil {
-		h.Logger.Error("failed to encode RSS feed", "error", err)
-	}
-}
-
-// sendAtomFeed sends an Atom feed response.
-func (h *BuddyFeedHandler) sendAtomFeed(w http.ResponseWriter, feed *AtomFeed) {
-	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
-
-	// Add XML declaration
-	_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
-
-	// Marshal and write the feed
-	encoder := xml.NewEncoder(w)
-	encoder.Indent("", "  ")
-	if err := encoder.Encode(feed); err != nil {
-		h.Logger.Error("failed to encode Atom feed", "error", err)
-	}
-}
-
-// sendEmptyRSSFeed sends an empty RSS feed.
-func (h *BuddyFeedHandler) sendEmptyRSSFeed(w http.ResponseWriter, screenName string) {
-	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
-
-	emptyFeed := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
-<rss version="2.0">
-  <channel>
-    <title>%s's Buddy Feed</title>
-    <description>Aggregated feed from your buddy list</description>
-    <link>/buddyfeed/getBuddylist</link>
-    <language>en-US</language>
-  </channel>
-</rss>`, screenName)
-
-	_, _ = w.Write([]byte(emptyFeed))
-}

+ 0 - 13
server/webapi/handlers/buddylist.go

@@ -219,19 +219,6 @@ func feedbagGroupMatchesRequested(storedName, requested string) bool {
 	return false
 	return false
 }
 }
 
 
-// findFeedbagGroupID returns the ItemID of a group matching requested, or false if none.
-func findFeedbagGroupID(items []wire.FeedbagItem, requested string) (uint16, bool) {
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdGroup {
-			continue
-		}
-		if feedbagGroupMatchesRequested(item.Name, requested) {
-			return item.ItemID, true
-		}
-	}
-	return 0, false
-}
-
 // storedGroupNameForRequest returns the feedbag group row Name for a Web client group label.
 // storedGroupNameForRequest returns the feedbag group row Name for a Web client group label.
 // Rows with GroupID 0 are the root order record, not a named buddy group.
 // Rows with GroupID 0 are the root order record, not a named buddy group.
 func storedGroupNameForRequest(items []wire.FeedbagItem, requested string) (string, bool) {
 func storedGroupNameForRequest(items []wire.FeedbagItem, requested string) (string, bool) {

+ 0 - 14
server/webapi/handlers/buddylist_test.go

@@ -240,20 +240,6 @@ func TestFeedbagGroupMatchesRequested(t *testing.T) {
 	assert.False(t, feedbagGroupMatchesRequested("", "Friends"))
 	assert.False(t, feedbagGroupMatchesRequested("", "Friends"))
 }
 }
 
 
-func TestFindFeedbagGroupID(t *testing.T) {
-	items := []wire.FeedbagItem{
-		{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 0},
-		{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "jon", GroupID: 1},
-	}
-	id, ok := findFeedbagGroupID(items, "Buddies")
-	assert.True(t, ok)
-	assert.Equal(t, uint16(1), id)
-
-	id, ok = findFeedbagGroupID(items, "Friends")
-	assert.False(t, ok)
-	assert.Equal(t, uint16(0), id)
-}
-
 func TestStoredGroupNameForRequest(t *testing.T) {
 func TestStoredGroupNameForRequest(t *testing.T) {
 	items := []wire.FeedbagItem{
 	items := []wire.FeedbagItem{
 		{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 1},
 		{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 1},

+ 0 - 232
server/webapi/handlers/feed_converter.go

@@ -1,232 +0,0 @@
-package handlers
-
-import (
-	"encoding/xml"
-	"time"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// FeedConverter handles conversion of feed data to various output formats.
-type FeedConverter struct{}
-
-// NewFeedConverter creates a new feed converter.
-func NewFeedConverter() *FeedConverter {
-	return &FeedConverter{}
-}
-
-// FeedResponse wraps feed data with conversion methods.
-type FeedResponse struct {
-	Feed  state.BuddyFeed       `json:"feed"`
-	Items []state.BuddyFeedItem `json:"items"`
-}
-
-// ToRSS converts the feed response to RSS format.
-func (fr *FeedResponse) ToRSS() *RSSFeed {
-	rss := &RSSFeed{
-		Version: "2.0",
-		Channel: RSSChannel{
-			Title:       fr.Feed.Title,
-			Link:        fr.Feed.Link,
-			Description: fr.Feed.Description,
-			Language:    "en-US",
-			PubDate:     fr.Feed.PublishedAt.Format(time.RFC1123Z),
-			Items:       make([]RSSItem, 0, len(fr.Items)),
-		},
-	}
-
-	for _, item := range fr.Items {
-		rssItem := RSSItem{
-			Title:       item.Title,
-			Link:        item.Link,
-			Description: item.Description,
-			Author:      item.Author,
-			Categories:  item.Categories,
-			GUID:        item.GUID,
-			PubDate:     item.PublishedAt.Format(time.RFC1123Z),
-		}
-		rss.Channel.Items = append(rss.Channel.Items, rssItem)
-	}
-
-	return rss
-}
-
-// ToAtom converts the feed response to Atom format.
-func (fr *FeedResponse) ToAtom() *AtomFeed {
-	atom := &AtomFeed{
-		Title:   fr.Feed.Title,
-		Link:    AtomLink{Href: fr.Feed.Link, Rel: "alternate"},
-		Updated: fr.Feed.UpdatedAt.Format(time.RFC3339),
-		ID:      fr.Feed.Link,
-		Author:  AtomAuthor{Name: fr.Feed.ScreenName},
-		Entries: make([]AtomEntry, 0, len(fr.Items)),
-	}
-
-	for _, item := range fr.Items {
-		entry := AtomEntry{
-			Title:     item.Title,
-			Link:      AtomLink{Href: item.Link},
-			ID:        item.GUID,
-			Updated:   item.PublishedAt.Format(time.RFC3339),
-			Published: item.PublishedAt.Format(time.RFC3339),
-			Author:    AtomAuthor{Name: item.Author},
-			Summary:   item.Description,
-			Content:   AtomContent{Type: "html", Text: item.Description},
-		}
-		atom.Entries = append(atom.Entries, entry)
-	}
-
-	return atom
-}
-
-// ToJSON converts the feed response to JSON format.
-func (fr *FeedResponse) ToJSON() map[string]interface{} {
-	jsonItems := make([]map[string]interface{}, 0, len(fr.Items))
-
-	for _, item := range fr.Items {
-		jsonItem := map[string]interface{}{
-			"id":          item.GUID,
-			"title":       item.Title,
-			"description": item.Description,
-			"link":        item.Link,
-			"author":      item.Author,
-			"categories":  item.Categories,
-			"published":   item.PublishedAt.Unix(),
-		}
-		jsonItems = append(jsonItems, jsonItem)
-	}
-
-	return map[string]interface{}{
-		"title":       fr.Feed.Title,
-		"description": fr.Feed.Description,
-		"link":        fr.Feed.Link,
-		"updated":     fr.Feed.UpdatedAt.Unix(),
-		"items":       jsonItems,
-	}
-}
-
-// RSS/Atom feed structures for XML output
-type RSSFeed struct {
-	XMLName xml.Name   `xml:"rss"`
-	Version string     `xml:"version,attr"`
-	Channel RSSChannel `xml:"channel"`
-}
-
-type RSSChannel struct {
-	Title       string    `xml:"title"`
-	Link        string    `xml:"link"`
-	Description string    `xml:"description"`
-	Language    string    `xml:"language,omitempty"`
-	PubDate     string    `xml:"pubDate,omitempty"`
-	Items       []RSSItem `xml:"item"`
-}
-
-type RSSItem struct {
-	Title       string   `xml:"title"`
-	Link        string   `xml:"link"`
-	Description string   `xml:"description"`
-	Author      string   `xml:"author,omitempty"`
-	Categories  []string `xml:"category,omitempty"`
-	GUID        string   `xml:"guid,omitempty"`
-	PubDate     string   `xml:"pubDate"`
-}
-
-type AtomFeed struct {
-	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
-	Title   string      `xml:"title"`
-	Link    AtomLink    `xml:"link"`
-	Updated string      `xml:"updated"`
-	Author  AtomAuthor  `xml:"author,omitempty"`
-	ID      string      `xml:"id"`
-	Entries []AtomEntry `xml:"entry"`
-}
-
-type AtomLink struct {
-	Href string `xml:"href,attr"`
-	Rel  string `xml:"rel,attr,omitempty"`
-}
-
-type AtomAuthor struct {
-	Name string `xml:"name"`
-}
-
-type AtomEntry struct {
-	Title     string      `xml:"title"`
-	Link      AtomLink    `xml:"link"`
-	ID        string      `xml:"id"`
-	Updated   string      `xml:"updated"`
-	Published string      `xml:"published,omitempty"`
-	Author    AtomAuthor  `xml:"author,omitempty"`
-	Summary   string      `xml:"summary,omitempty"`
-	Content   AtomContent `xml:"content,omitempty"`
-}
-
-type AtomContent struct {
-	Type string `xml:"type,attr"`
-	Text string `xml:",chardata"`
-}
-
-// GenerateEmptyFeed creates an empty feed for users without configured feeds.
-func GenerateEmptyFeed(screenName string) *FeedResponse {
-	feed := state.BuddyFeed{
-		ScreenName:  screenName,
-		Title:       screenName + "'s Feed",
-		Description: "No updates from " + screenName,
-		Link:        "/buddyfeed/getUser?u=" + screenName,
-		PublishedAt: time.Now(),
-		UpdatedAt:   time.Now(),
-	}
-
-	return &FeedResponse{
-		Feed:  feed,
-		Items: []state.BuddyFeedItem{},
-	}
-}
-
-// BuildFeedData creates feed data map from request parameters.
-func BuildFeedData(params map[string]string) map[string]interface{} {
-	feedData := make(map[string]interface{})
-
-	// Required fields
-	if title, ok := params["itemTitle"]; ok {
-		feedData["title"] = title
-	}
-	if desc, ok := params["itemDesc"]; ok {
-		feedData["description"] = desc
-	}
-	if link, ok := params["itemLink"]; ok {
-		feedData["link"] = link
-	}
-	if guid, ok := params["itemGuid"]; ok {
-		feedData["guid"] = guid
-	}
-
-	// Feed metadata
-	if feedTitle, ok := params["feedTitle"]; ok {
-		feedData["feedTitle"] = feedTitle
-	}
-	if feedLink, ok := params["feedLink"]; ok {
-		feedData["feedLink"] = feedLink
-	}
-	if feedDesc, ok := params["feedDesc"]; ok {
-		feedData["feedDesc"] = feedDesc
-	}
-
-	// Optional fields
-	if publisher, ok := params["feedPublisher"]; ok && publisher != "" {
-		feedData["publisher"] = publisher
-	}
-	if pubDate, ok := params["itemPubDate"]; ok && pubDate != "" {
-		feedData["pubDate"] = pubDate
-	}
-	if category, ok := params["itemCategory"]; ok && category != "" {
-		feedData["categories"] = []string{category}
-	}
-
-	// Default type if not specified
-	if _, ok := feedData["type"]; !ok {
-		feedData["type"] = "status"
-	}
-
-	return feedData
-}

+ 0 - 323
server/webapi/handlers/vanity.go

@@ -1,323 +0,0 @@
-package handlers
-
-import (
-	"log/slog"
-	"net/http"
-	"strings"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// VanityHandler handles Web AIM API vanity URL endpoints.
-type VanityHandler struct {
-	SessionManager *state.WebAPISessionManager
-	VanityManager  *state.VanityURLManager
-	Logger         *slog.Logger
-}
-
-// GetVanityInfo handles GET /aim/getVanityInfo requests to retrieve vanity URL information.
-func (h *VanityHandler) GetVanityInfo(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// According to spec, this endpoint requires signed request parameters
-	// but we'll make them optional for compatibility
-	ts := r.URL.Query().Get("ts")
-	sig := r.URL.Query().Get("sig_sha256")
-
-	// Validate timestamp if provided
-	if ts != "" && sig == "" {
-		SendError(w, http.StatusBadRequest, "signature required when timestamp provided")
-		return
-	}
-
-	// Get authentication from either aimsid or token
-	aimsid := r.URL.Query().Get("aimsid")
-	_ = r.URL.Query().Get("a") // Token auth not fully implemented
-
-	var screenName string
-	if aimsid != "" {
-		session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-		if err == nil {
-			screenName = session.ScreenName.String()
-		}
-	}
-
-	// If no explicit target, use authenticated user
-	targetUser := r.URL.Query().Get("t")
-	if targetUser == "" && screenName != "" {
-		targetUser = screenName
-	}
-
-	if targetUser == "" {
-		SendError(w, http.StatusBadRequest, "missing target user")
-		return
-	}
-
-	h.Logger.DebugContext(ctx, "retrieving vanity info",
-		"targetUser", targetUser,
-		"authenticated", screenName,
-	)
-
-	// Lookup vanity info by screen name
-	info, err := h.VanityManager.GetVanityInfoByScreenName(ctx, targetUser)
-
-	// Handle error or no vanity URL found
-	if err != nil || info == nil {
-		if err != nil && !strings.Contains(err.Error(), "not found") {
-			h.Logger.ErrorContext(ctx, "failed to get vanity info",
-				"error", err,
-			)
-			SendError(w, http.StatusInternalServerError, "failed to retrieve vanity info")
-			return
-		}
-
-		// No vanity URL configured - return not found
-		response := BaseResponse{}
-		response.Response.StatusCode = 200
-		response.Response.StatusText = "OK"
-		response.Response.Data = map[string]interface{}{
-			"found":      false,
-			"screenName": targetUser,
-		}
-		SendResponse(w, r, response, h.Logger)
-		return
-	}
-
-	// Build response
-	responseData := map[string]interface{}{
-		"found":      true,
-		"screenName": info.ScreenName,
-		"vanityUrl":  info.VanityURL,
-		"profileUrl": info.ProfileURL,
-		"isActive":   info.IsActive,
-	}
-
-	// Add optional fields if present
-	if info.DisplayName != "" {
-		responseData["displayName"] = info.DisplayName
-	}
-	if info.Bio != "" {
-		responseData["bio"] = info.Bio
-	}
-	if info.Location != "" {
-		responseData["location"] = info.Location
-	}
-	if info.Website != "" {
-		responseData["website"] = info.Website
-	}
-
-	// Add extra data if present
-	if info.Extra != nil {
-		for k, v := range info.Extra {
-			responseData[k] = v
-		}
-	}
-
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = responseData
-
-	SendResponse(w, r, response, h.Logger)
-}
-
-// SetVanityURL handles requests to set or update a vanity URL (requires authentication).
-func (h *VanityHandler) SetVanityURL(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Authentication required
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		SendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		SendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
-	// Get vanity URL from parameters
-	vanityURL := r.URL.Query().Get("vanityUrl")
-	if vanityURL == "" {
-		SendError(w, http.StatusBadRequest, "missing vanityUrl parameter")
-		return
-	}
-
-	// Collect optional profile information
-	info := make(map[string]interface{})
-	if displayName := r.URL.Query().Get("displayName"); displayName != "" {
-		info["displayName"] = displayName
-	}
-	if bio := r.URL.Query().Get("bio"); bio != "" {
-		info["bio"] = bio
-	}
-	if location := r.URL.Query().Get("location"); location != "" {
-		info["location"] = location
-	}
-	if website := r.URL.Query().Get("website"); website != "" {
-		info["website"] = website
-	}
-
-	h.Logger.InfoContext(ctx, "setting vanity URL",
-		"screenName", session.ScreenName.String(),
-		"vanityUrl", vanityURL,
-	)
-
-	// Create or update the vanity URL
-	if err := h.VanityManager.CreateOrUpdateVanityURL(ctx, session.ScreenName.String(), vanityURL, info); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to set vanity URL",
-			"screenName", session.ScreenName.String(),
-			"vanityUrl", vanityURL,
-			"error", err,
-		)
-
-		// Check if it's a validation or duplicate error
-		if strings.Contains(err.Error(), "reserved") ||
-			strings.Contains(err.Error(), "already taken") ||
-			strings.Contains(err.Error(), "must be") ||
-			strings.Contains(err.Error(), "cannot") {
-			SendError(w, http.StatusBadRequest, err.Error())
-			return
-		}
-
-		SendError(w, http.StatusInternalServerError, "failed to set vanity URL")
-		return
-	}
-
-	// Return success response with the new vanity info
-	vanityInfo, _ := h.VanityManager.GetVanityInfoByScreenName(ctx, session.ScreenName.String())
-
-	responseData := map[string]interface{}{
-		"success":    true,
-		"screenName": session.ScreenName.String(),
-		"vanityUrl":  vanityURL,
-	}
-
-	if vanityInfo != nil {
-		responseData["profileUrl"] = vanityInfo.ProfileURL
-	}
-
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = responseData
-
-	SendResponse(w, r, response, h.Logger)
-}
-
-// CheckAvailability handles requests to check if a vanity URL is available.
-func (h *VanityHandler) CheckAvailability(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Get vanity URL from parameters
-	vanityURL := r.URL.Query().Get("vanityUrl")
-	if vanityURL == "" {
-		SendError(w, http.StatusBadRequest, "missing vanityUrl parameter")
-		return
-	}
-
-	h.Logger.DebugContext(ctx, "checking vanity URL availability",
-		"vanityUrl", vanityURL,
-	)
-
-	// Check availability
-	available, err := h.VanityManager.CheckAvailability(ctx, vanityURL)
-	if err != nil {
-		// If it's a validation error, return it as a bad request
-		if strings.Contains(err.Error(), "must be") ||
-			strings.Contains(err.Error(), "cannot") ||
-			strings.Contains(err.Error(), "can only") {
-			response := BaseResponse{}
-			response.Response.StatusCode = 200
-			response.Response.StatusText = "OK"
-			response.Response.Data = map[string]interface{}{
-				"available": false,
-				"reason":    err.Error(),
-			}
-			SendResponse(w, r, response, h.Logger)
-			return
-		}
-
-		h.Logger.ErrorContext(ctx, "failed to check availability",
-			"vanityUrl", vanityURL,
-			"error", err,
-		)
-		SendError(w, http.StatusInternalServerError, "failed to check availability")
-		return
-	}
-
-	// Build response
-	responseData := map[string]interface{}{
-		"available": available,
-		"vanityUrl": vanityURL,
-	}
-
-	if !available {
-		responseData["reason"] = "This vanity URL is already taken or reserved"
-	}
-
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = responseData
-
-	SendResponse(w, r, response, h.Logger)
-}
-
-// DeleteVanityURL handles requests to delete a vanity URL (requires authentication).
-func (h *VanityHandler) DeleteVanityURL(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Authentication required
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		SendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		SendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
-	h.Logger.InfoContext(ctx, "deleting vanity URL",
-		"screenName", session.ScreenName.String(),
-	)
-
-	// Delete the vanity URL
-	if err := h.VanityManager.DeleteVanityURL(ctx, session.ScreenName.String()); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to delete vanity URL",
-			"screenName", session.ScreenName.String(),
-			"error", err,
-		)
-		SendError(w, http.StatusInternalServerError, "failed to delete vanity URL")
-		return
-	}
-
-	// Return success response
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = map[string]interface{}{
-		"success":    true,
-		"screenName": session.ScreenName.String(),
-		"message":    "Vanity URL deleted successfully",
-	}
-
-	SendResponse(w, r, response, h.Logger)
-}

+ 0 - 60
server/webapi/middleware/auth.go

@@ -116,12 +116,6 @@ func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
 	}
 	}
 }
 }
 
 
-// Allow checks if a request from the given devID is allowed based on rate limits.
-func (r *RateLimiter) Allow(devID string, limit int) bool {
-	info := r.CheckRateLimit(devID, limit)
-	return info.Allowed
-}
-
 // AuthMiddleware provides authentication and rate limiting for Web API endpoints.
 // AuthMiddleware provides authentication and rate limiting for Web API endpoints.
 type AuthMiddleware struct {
 type AuthMiddleware struct {
 	Validator   APIKeyValidator
 	Validator   APIKeyValidator
@@ -254,48 +248,6 @@ func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
 	})
 	})
 }
 }
 
 
-// CapabilitiesMiddleware checks if the API key has the required capability for an endpoint.
-func (m *AuthMiddleware) CapabilitiesMiddleware(requiredCapability string) func(http.Handler) http.Handler {
-	return func(next http.Handler) http.Handler {
-		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			// Get API key from context
-			key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
-			if !ok {
-				m.Logger.Error("CapabilitiesMiddleware called without authentication context")
-				http.Error(w, "internal server error", http.StatusInternalServerError)
-				return
-			}
-
-			// If no capabilities are defined, allow all (backward compatibility)
-			if len(key.Capabilities) == 0 {
-				next.ServeHTTP(w, r)
-				return
-			}
-
-			// Check if required capability is present
-			hasCapability := false
-			for _, cap := range key.Capabilities {
-				if cap == requiredCapability || cap == "*" {
-					hasCapability = true
-					break
-				}
-			}
-
-			if !hasCapability {
-				m.Logger.WarnContext(r.Context(), "capability check failed",
-					"dev_id", key.DevID,
-					"required", requiredCapability,
-					"available", key.Capabilities,
-				)
-				m.sendErrorResponse(w, r, http.StatusForbidden, fmt.Sprintf("missing required capability: %s", requiredCapability))
-				return
-			}
-
-			next.ServeHTTP(w, r)
-		})
-	}
-}
-
 // isOriginAllowed checks if an origin is in the allowed list.
 // isOriginAllowed checks if an origin is in the allowed list.
 func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
 func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
 	// If no origins specified, allow all (for backward compatibility/development)
 	// If no origins specified, allow all (for backward compatibility/development)
@@ -382,18 +334,6 @@ func isValidJSONPCallback(callback string) bool {
 	return true
 	return true
 }
 }
 
 
-// GetAPIKeyFromContext retrieves the API key from the request context.
-func GetAPIKeyFromContext(ctx context.Context) (*state.WebAPIKey, bool) {
-	key, ok := ctx.Value(ContextKeyAPIKey).(*state.WebAPIKey)
-	return key, ok
-}
-
-// GetDevIDFromContext retrieves the developer ID from the request context.
-func GetDevIDFromContext(ctx context.Context) (string, bool) {
-	devID, ok := ctx.Value(ContextKeyDevID).(string)
-	return devID, ok
-}
-
 // min returns the minimum of two integers.
 // min returns the minimum of two integers.
 func min(a, b int) int {
 func min(a, b int) int {
 	if a < b {
 	if a < b {

+ 138 - 0
state/migrations/0039_drop_dead_webapi_features.down.sql

@@ -0,0 +1,138 @@
+-- Rollback: recreate the analytics (0019), buddy feed (0020), and
+-- vanity URL (0021) tables that 0039 dropped.
+
+-- API usage analytics (from 0019_api_analytics).
+CREATE TABLE IF NOT EXISTS api_usage_logs (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    dev_id VARCHAR(255) NOT NULL,
+    endpoint VARCHAR(255) NOT NULL,
+    method VARCHAR(10) NOT NULL,
+    timestamp INTEGER NOT NULL,
+    response_time_ms INTEGER,
+    status_code INTEGER,
+    ip_address VARCHAR(45),
+    user_agent TEXT,
+    screen_name VARCHAR(16),
+    error_message TEXT,
+    request_size INTEGER,
+    response_size INTEGER
+);
+
+CREATE INDEX idx_usage_dev_id ON api_usage_logs(dev_id);
+CREATE INDEX idx_usage_timestamp ON api_usage_logs(timestamp);
+CREATE INDEX idx_usage_endpoint ON api_usage_logs(endpoint);
+CREATE INDEX idx_usage_status ON api_usage_logs(status_code);
+CREATE INDEX idx_usage_screen_name ON api_usage_logs(screen_name);
+
+CREATE TABLE IF NOT EXISTS api_usage_stats (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    dev_id VARCHAR(255) NOT NULL,
+    endpoint VARCHAR(255) NOT NULL,
+    period_type VARCHAR(10) NOT NULL,
+    period_start INTEGER NOT NULL,
+    request_count INTEGER DEFAULT 0,
+    error_count INTEGER DEFAULT 0,
+    total_response_time_ms INTEGER DEFAULT 0,
+    avg_response_time_ms INTEGER DEFAULT 0,
+    total_request_bytes INTEGER DEFAULT 0,
+    total_response_bytes INTEGER DEFAULT 0,
+    unique_users INTEGER DEFAULT 0,
+    UNIQUE(dev_id, endpoint, period_type, period_start)
+);
+
+CREATE INDEX idx_stats_dev_id ON api_usage_stats(dev_id);
+CREATE INDEX idx_stats_period ON api_usage_stats(period_type, period_start);
+CREATE INDEX idx_stats_endpoint ON api_usage_stats(endpoint);
+
+CREATE TABLE IF NOT EXISTS api_quotas (
+    dev_id VARCHAR(255) PRIMARY KEY,
+    daily_limit INTEGER DEFAULT 10000,
+    monthly_limit INTEGER DEFAULT 300000,
+    daily_used INTEGER DEFAULT 0,
+    monthly_used INTEGER DEFAULT 0,
+    last_reset_daily INTEGER NOT NULL,
+    last_reset_monthly INTEGER NOT NULL,
+    overage_allowed BOOLEAN DEFAULT FALSE
+);
+
+-- Buddy feeds (from 0020_buddy_feeds).
+CREATE TABLE IF NOT EXISTS buddy_feeds (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    screen_name VARCHAR(16) NOT NULL,
+    feed_type VARCHAR(50) NOT NULL,
+    title TEXT,
+    description TEXT,
+    link TEXT,
+    published_at INTEGER NOT NULL,
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL,
+    is_active BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX idx_buddy_feeds_screen_name ON buddy_feeds(screen_name);
+CREATE INDEX idx_buddy_feeds_published ON buddy_feeds(published_at);
+CREATE INDEX idx_buddy_feeds_type ON buddy_feeds(feed_type);
+CREATE INDEX idx_buddy_feeds_active ON buddy_feeds(is_active);
+
+CREATE TABLE IF NOT EXISTS buddy_feed_items (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    feed_id INTEGER NOT NULL,
+    title TEXT NOT NULL,
+    description TEXT,
+    link TEXT,
+    guid TEXT,
+    author VARCHAR(16),
+    categories TEXT,
+    published_at INTEGER NOT NULL,
+    created_at INTEGER NOT NULL,
+    FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_feed_items_feed_id ON buddy_feed_items(feed_id);
+CREATE INDEX idx_feed_items_published ON buddy_feed_items(published_at);
+CREATE INDEX idx_feed_items_guid ON buddy_feed_items(guid);
+
+CREATE TABLE IF NOT EXISTS buddy_feed_subscriptions (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    subscriber_screen_name VARCHAR(16) NOT NULL,
+    feed_id INTEGER NOT NULL,
+    subscribed_at INTEGER NOT NULL,
+    last_checked_at INTEGER,
+    FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE,
+    UNIQUE(subscriber_screen_name, feed_id)
+);
+
+CREATE INDEX idx_feed_subs_subscriber ON buddy_feed_subscriptions(subscriber_screen_name);
+CREATE INDEX idx_feed_subs_feed_id ON buddy_feed_subscriptions(feed_id);
+
+-- Vanity URLs (from 0021_vanity_urls).
+CREATE TABLE IF NOT EXISTS vanity_urls (
+    screen_name VARCHAR(16) PRIMARY KEY,
+    vanity_url VARCHAR(255) UNIQUE NOT NULL,
+    display_name VARCHAR(100),
+    bio TEXT,
+    location VARCHAR(100),
+    website VARCHAR(255),
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL,
+    is_active BOOLEAN DEFAULT TRUE,
+    click_count INTEGER DEFAULT 0,
+    last_accessed INTEGER
+);
+
+CREATE INDEX idx_vanity_urls_url ON vanity_urls(vanity_url);
+CREATE INDEX idx_vanity_urls_active ON vanity_urls(is_active);
+CREATE INDEX idx_vanity_urls_created ON vanity_urls(created_at);
+
+CREATE TABLE IF NOT EXISTS vanity_url_redirects (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    vanity_url VARCHAR(255) NOT NULL,
+    accessed_at INTEGER NOT NULL,
+    ip_address VARCHAR(45),
+    user_agent TEXT,
+    referer TEXT,
+    FOREIGN KEY (vanity_url) REFERENCES vanity_urls(vanity_url) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_vanity_redirects_url ON vanity_url_redirects(vanity_url);
+CREATE INDEX idx_vanity_redirects_time ON vanity_url_redirects(accessed_at);

+ 15 - 0
state/migrations/0039_drop_dead_webapi_features.up.sql

@@ -0,0 +1,15 @@
+-- Drop tables for removed Web API features: usage analytics (0019),
+-- buddy feeds (0020), and vanity URLs (0021). The Go code backing these
+-- features has been deleted; the tables were never wired into the server.
+
+-- Child tables (foreign keys) first.
+DROP TABLE IF EXISTS buddy_feed_subscriptions;
+DROP TABLE IF EXISTS buddy_feed_items;
+DROP TABLE IF EXISTS buddy_feeds;
+
+DROP TABLE IF EXISTS vanity_url_redirects;
+DROP TABLE IF EXISTS vanity_urls;
+
+DROP TABLE IF EXISTS api_usage_logs;
+DROP TABLE IF EXISTS api_usage_stats;
+DROP TABLE IF EXISTS api_quotas;

+ 0 - 429
state/webapi_analytics.go

@@ -1,429 +0,0 @@
-package state
-
-import (
-	"context"
-	"database/sql"
-	"fmt"
-	"log/slog"
-	"net/http"
-	"strings"
-	"sync"
-	"time"
-)
-
-// APIUsageLog represents a single API request log entry.
-type APIUsageLog struct {
-	ID             int64     `json:"id"`
-	DevID          string    `json:"dev_id"`
-	Endpoint       string    `json:"endpoint"`
-	Method         string    `json:"method"`
-	Timestamp      time.Time `json:"timestamp"`
-	ResponseTimeMs int       `json:"response_time_ms"`
-	StatusCode     int       `json:"status_code"`
-	IPAddress      string    `json:"ip_address"`
-	UserAgent      string    `json:"user_agent"`
-	ScreenName     string    `json:"screen_name,omitempty"`
-	ErrorMessage   string    `json:"error_message,omitempty"`
-	RequestSize    int       `json:"request_size"`
-	ResponseSize   int       `json:"response_size"`
-}
-
-// APIUsageStats represents aggregated API usage statistics.
-type APIUsageStats struct {
-	DevID              string    `json:"dev_id"`
-	Endpoint           string    `json:"endpoint"`
-	PeriodType         string    `json:"period_type"`
-	PeriodStart        time.Time `json:"period_start"`
-	RequestCount       int       `json:"request_count"`
-	ErrorCount         int       `json:"error_count"`
-	TotalResponseTime  int       `json:"total_response_time_ms"`
-	AvgResponseTime    int       `json:"avg_response_time_ms"`
-	TotalRequestBytes  int64     `json:"total_request_bytes"`
-	TotalResponseBytes int64     `json:"total_response_bytes"`
-	UniqueUsers        int       `json:"unique_users"`
-}
-
-// APIQuota represents API usage quotas for a developer.
-type APIQuota struct {
-	DevID            string    `json:"dev_id"`
-	DailyLimit       int       `json:"daily_limit"`
-	MonthlyLimit     int       `json:"monthly_limit"`
-	DailyUsed        int       `json:"daily_used"`
-	MonthlyUsed      int       `json:"monthly_used"`
-	LastResetDaily   time.Time `json:"last_reset_daily"`
-	LastResetMonthly time.Time `json:"last_reset_monthly"`
-	OverageAllowed   bool      `json:"overage_allowed"`
-}
-
-// APIAnalytics provides analytics tracking for the Web API.
-type APIAnalytics struct {
-	db        *sql.DB
-	logger    *slog.Logger
-	batchSize int
-	buffer    []APIUsageLog
-	bufferMu  sync.Mutex
-	ticker    *time.Ticker
-	done      chan bool
-}
-
-// NewAPIAnalytics creates a new API analytics instance.
-func NewAPIAnalytics(db *sql.DB, logger *slog.Logger) *APIAnalytics {
-	analytics := &APIAnalytics{
-		db:        db,
-		logger:    logger,
-		batchSize: 100,
-		buffer:    make([]APIUsageLog, 0, 100),
-		ticker:    time.NewTicker(5 * time.Second),
-		done:      make(chan bool),
-	}
-
-	// Start background worker for batch processing
-	go analytics.batchProcessor()
-
-	return analytics
-}
-
-// LogRequest logs an API request asynchronously.
-func (a *APIAnalytics) LogRequest(ctx context.Context, log APIUsageLog) {
-	a.bufferMu.Lock()
-	defer a.bufferMu.Unlock()
-
-	a.buffer = append(a.buffer, log)
-
-	// Flush if buffer is full
-	if len(a.buffer) >= a.batchSize {
-		go a.flush(context.Background())
-	}
-}
-
-// LogHTTPRequest logs an HTTP request with timing information.
-func (a *APIAnalytics) LogHTTPRequest(ctx context.Context, r *http.Request, statusCode int, responseTime time.Duration, responseSize int, errorMsg string) {
-	// Extract IP address
-	ip := r.RemoteAddr
-	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
-		ip = strings.Split(forwarded, ",")[0]
-	}
-
-	// Get request size
-	requestSize := 0
-	if r.ContentLength > 0 {
-		requestSize = int(r.ContentLength)
-	}
-
-	// Extract dev_id from context (set by auth middleware)
-	devID := ""
-	if val := r.Context().Value("dev_id"); val != nil {
-		devID = val.(string)
-	}
-
-	// Extract screen name if available
-	screenName := ""
-	if val := r.Context().Value("screen_name"); val != nil {
-		screenName = val.(string)
-	}
-
-	log := APIUsageLog{
-		DevID:          devID,
-		Endpoint:       r.URL.Path,
-		Method:         r.Method,
-		Timestamp:      time.Now(),
-		ResponseTimeMs: int(responseTime.Milliseconds()),
-		StatusCode:     statusCode,
-		IPAddress:      ip,
-		UserAgent:      r.UserAgent(),
-		ScreenName:     screenName,
-		ErrorMessage:   errorMsg,
-		RequestSize:    requestSize,
-		ResponseSize:   responseSize,
-	}
-
-	a.LogRequest(ctx, log)
-}
-
-// batchProcessor processes buffered logs in batches.
-func (a *APIAnalytics) batchProcessor() {
-	for {
-		select {
-		case <-a.ticker.C:
-			a.flush(context.Background())
-		case <-a.done:
-			a.flush(context.Background()) // Final flush
-			return
-		}
-	}
-}
-
-// flush writes buffered logs to the database.
-func (a *APIAnalytics) flush(ctx context.Context) {
-	a.bufferMu.Lock()
-	if len(a.buffer) == 0 {
-		a.bufferMu.Unlock()
-		return
-	}
-
-	// Copy buffer and clear it
-	logs := make([]APIUsageLog, len(a.buffer))
-	copy(logs, a.buffer)
-	a.buffer = a.buffer[:0]
-	a.bufferMu.Unlock()
-
-	// Insert logs in a transaction
-	tx, err := a.db.Begin()
-	if err != nil {
-		a.logger.Error("failed to begin transaction for analytics", "error", err)
-		return
-	}
-	defer tx.Rollback()
-
-	stmt, err := tx.Prepare(`
-		INSERT INTO api_usage_logs (
-			dev_id, endpoint, method, timestamp, response_time_ms,
-			status_code, ip_address, user_agent, screen_name,
-			error_message, request_size, response_size
-		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-	`)
-	if err != nil {
-		a.logger.Error("failed to prepare analytics insert statement", "error", err)
-		return
-	}
-	defer func() { _ = stmt.Close() }()
-
-	for _, log := range logs {
-		_, err := stmt.Exec(
-			log.DevID, log.Endpoint, log.Method, log.Timestamp.Unix(),
-			log.ResponseTimeMs, log.StatusCode, log.IPAddress, log.UserAgent,
-			nullString(log.ScreenName), nullString(log.ErrorMessage),
-			log.RequestSize, log.ResponseSize,
-		)
-		if err != nil {
-			a.logger.Error("failed to insert analytics log", "error", err)
-			continue
-		}
-	}
-
-	if err := tx.Commit(); err != nil {
-		a.logger.Error("failed to commit analytics transaction", "error", err)
-	}
-}
-
-// GetUsageStats retrieves aggregated usage statistics for a developer.
-func (a *APIAnalytics) GetUsageStats(ctx context.Context, devID string, periodType string, startTime, endTime time.Time) ([]APIUsageStats, error) {
-	query := `
-		SELECT 
-			dev_id, endpoint, COUNT(*) as request_count,
-			SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as error_count,
-			SUM(response_time_ms) as total_response_time,
-			AVG(response_time_ms) as avg_response_time,
-			SUM(request_size) as total_request_bytes,
-			SUM(response_size) as total_response_bytes,
-			COUNT(DISTINCT screen_name) as unique_users
-		FROM api_usage_logs
-		WHERE dev_id = ? AND timestamp >= ? AND timestamp <= ?
-		GROUP BY dev_id, endpoint
-		ORDER BY request_count DESC
-	`
-
-	rows, err := a.db.QueryContext(ctx, query, devID, startTime.Unix(), endTime.Unix())
-	if err != nil {
-		return nil, fmt.Errorf("failed to query usage stats: %w", err)
-	}
-	defer rows.Close()
-
-	var stats []APIUsageStats
-	for rows.Next() {
-		var s APIUsageStats
-		err := rows.Scan(
-			&s.DevID, &s.Endpoint, &s.RequestCount,
-			&s.ErrorCount, &s.TotalResponseTime, &s.AvgResponseTime,
-			&s.TotalRequestBytes, &s.TotalResponseBytes, &s.UniqueUsers,
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to scan usage stats: %w", err)
-		}
-		s.PeriodType = periodType
-		s.PeriodStart = startTime
-		stats = append(stats, s)
-	}
-
-	return stats, nil
-}
-
-// GetTopEndpoints retrieves the most used endpoints for a developer.
-func (a *APIAnalytics) GetTopEndpoints(ctx context.Context, devID string, limit int) ([]struct {
-	Endpoint string `json:"endpoint"`
-	Count    int    `json:"count"`
-}, error) {
-	query := `
-		SELECT endpoint, COUNT(*) as count
-		FROM api_usage_logs
-		WHERE dev_id = ? AND timestamp >= ?
-		GROUP BY endpoint
-		ORDER BY count DESC
-		LIMIT ?
-	`
-
-	// Look at last 24 hours
-	since := time.Now().Add(-24 * time.Hour).Unix()
-
-	rows, err := a.db.QueryContext(ctx, query, devID, since, limit)
-	if err != nil {
-		return nil, fmt.Errorf("failed to query top endpoints: %w", err)
-	}
-	defer rows.Close()
-
-	var endpoints []struct {
-		Endpoint string `json:"endpoint"`
-		Count    int    `json:"count"`
-	}
-
-	for rows.Next() {
-		var e struct {
-			Endpoint string `json:"endpoint"`
-			Count    int    `json:"count"`
-		}
-		if err := rows.Scan(&e.Endpoint, &e.Count); err != nil {
-			return nil, fmt.Errorf("failed to scan endpoint: %w", err)
-		}
-		endpoints = append(endpoints, e)
-	}
-
-	return endpoints, nil
-}
-
-// CheckQuota checks if a developer has exceeded their usage quota.
-func (a *APIAnalytics) CheckQuota(ctx context.Context, devID string) (bool, *APIQuota, error) {
-	// Get or create quota record
-	quota, err := a.getOrCreateQuota(ctx, devID)
-	if err != nil {
-		return false, nil, err
-	}
-
-	// Check if quotas need to be reset
-	now := time.Now()
-	needsUpdate := false
-
-	// Reset daily quota if needed
-	if now.Sub(quota.LastResetDaily) >= 24*time.Hour {
-		quota.DailyUsed = 0
-		quota.LastResetDaily = now.Truncate(24 * time.Hour)
-		needsUpdate = true
-	}
-
-	// Reset monthly quota if needed
-	if now.Month() != quota.LastResetMonthly.Month() || now.Year() != quota.LastResetMonthly.Year() {
-		quota.MonthlyUsed = 0
-		quota.LastResetMonthly = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
-		needsUpdate = true
-	}
-
-	// Update quota if needed
-	if needsUpdate {
-		if err := a.updateQuota(ctx, quota); err != nil {
-			return false, nil, err
-		}
-	}
-
-	// Check if within limits
-	withinLimits := (quota.DailyUsed < quota.DailyLimit && quota.MonthlyUsed < quota.MonthlyLimit) || quota.OverageAllowed
-
-	return withinLimits, quota, nil
-}
-
-// IncrementQuotaUsage increments the usage counters for a developer.
-func (a *APIAnalytics) IncrementQuotaUsage(ctx context.Context, devID string) error {
-	query := `
-		UPDATE api_quotas
-		SET daily_used = daily_used + 1,
-		    monthly_used = monthly_used + 1
-		WHERE dev_id = ?
-	`
-
-	_, err := a.db.ExecContext(ctx, query, devID)
-	return err
-}
-
-// getOrCreateQuota retrieves or creates a quota record for a developer.
-func (a *APIAnalytics) getOrCreateQuota(ctx context.Context, devID string) (*APIQuota, error) {
-	quota := &APIQuota{DevID: devID}
-
-	query := `
-		SELECT daily_limit, monthly_limit, daily_used, monthly_used,
-		       last_reset_daily, last_reset_monthly, overage_allowed
-		FROM api_quotas
-		WHERE dev_id = ?
-	`
-
-	err := a.db.QueryRowContext(ctx, query, devID).Scan(
-		&quota.DailyLimit, &quota.MonthlyLimit,
-		&quota.DailyUsed, &quota.MonthlyUsed,
-		&quota.LastResetDaily, &quota.LastResetMonthly,
-		&quota.OverageAllowed,
-	)
-
-	if err == sql.ErrNoRows {
-		// Create default quota
-		now := time.Now()
-		quota = &APIQuota{
-			DevID:            devID,
-			DailyLimit:       10000,
-			MonthlyLimit:     300000,
-			DailyUsed:        0,
-			MonthlyUsed:      0,
-			LastResetDaily:   now.Truncate(24 * time.Hour),
-			LastResetMonthly: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()),
-			OverageAllowed:   false,
-		}
-
-		insertQuery := `
-			INSERT INTO api_quotas (
-				dev_id, daily_limit, monthly_limit, daily_used, monthly_used,
-				last_reset_daily, last_reset_monthly, overage_allowed
-			) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-		`
-
-		_, err = a.db.ExecContext(ctx, insertQuery,
-			quota.DevID, quota.DailyLimit, quota.MonthlyLimit,
-			quota.DailyUsed, quota.MonthlyUsed,
-			quota.LastResetDaily.Unix(), quota.LastResetMonthly.Unix(),
-			quota.OverageAllowed,
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to create quota: %w", err)
-		}
-	} else if err != nil {
-		return nil, fmt.Errorf("failed to get quota: %w", err)
-	}
-
-	return quota, nil
-}
-
-// updateQuota updates a quota record.
-func (a *APIAnalytics) updateQuota(ctx context.Context, quota *APIQuota) error {
-	query := `
-		UPDATE api_quotas
-		SET daily_used = ?, monthly_used = ?,
-		    last_reset_daily = ?, last_reset_monthly = ?
-		WHERE dev_id = ?
-	`
-
-	_, err := a.db.ExecContext(ctx, query,
-		quota.DailyUsed, quota.MonthlyUsed,
-		quota.LastResetDaily.Unix(), quota.LastResetMonthly.Unix(),
-		quota.DevID,
-	)
-	return err
-}
-
-// Close stops the analytics processor.
-func (a *APIAnalytics) Close() {
-	close(a.done)
-	a.ticker.Stop()
-}
-
-// nullString returns a sql.NullString for the given string.
-func nullString(s string) sql.NullString {
-	if s == "" {
-		return sql.NullString{Valid: false}
-	}
-	return sql.NullString{String: s, Valid: true}
-}

+ 0 - 330
state/webapi_buddyfeed.go

@@ -1,330 +0,0 @@
-package state
-
-import (
-	"context"
-	"database/sql"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"strings"
-	"time"
-)
-
-// BuddyFeed represents a user's feed configuration.
-type BuddyFeed struct {
-	ID          int64     `json:"id"`
-	ScreenName  string    `json:"screenName"`
-	FeedType    string    `json:"feedType"`
-	Title       string    `json:"title"`
-	Description string    `json:"description"`
-	Link        string    `json:"link"`
-	PublishedAt time.Time `json:"publishedAt"`
-	CreatedAt   time.Time `json:"createdAt"`
-	UpdatedAt   time.Time `json:"updatedAt"`
-	IsActive    bool      `json:"isActive"`
-}
-
-// BuddyFeedItem represents an individual feed entry.
-type BuddyFeedItem struct {
-	ID          int64     `json:"id"`
-	FeedID      int64     `json:"feedId"`
-	Title       string    `json:"title"`
-	Description string    `json:"description"`
-	Link        string    `json:"link"`
-	GUID        string    `json:"guid"`
-	Author      string    `json:"author"`
-	Categories  []string  `json:"categories"`
-	PublishedAt time.Time `json:"publishedAt"`
-	CreatedAt   time.Time `json:"createdAt"`
-}
-
-// BuddyFeedSubscription represents a feed subscription.
-type BuddyFeedSubscription struct {
-	ID                   int64      `json:"id"`
-	SubscriberScreenName string     `json:"subscriberScreenName"`
-	FeedID               int64      `json:"feedId"`
-	SubscribedAt         time.Time  `json:"subscribedAt"`
-	LastCheckedAt        *time.Time `json:"lastCheckedAt"`
-}
-
-// BuddyFeedManager manages buddy feed operations.
-type BuddyFeedManager struct {
-	db     *sql.DB
-	logger *slog.Logger
-}
-
-// NewBuddyFeedManager creates a new buddy feed manager.
-func NewBuddyFeedManager(db *sql.DB, logger *slog.Logger) *BuddyFeedManager {
-	return &BuddyFeedManager{
-		db:     db,
-		logger: logger,
-	}
-}
-
-// CreateFeed creates a new buddy feed.
-func (m *BuddyFeedManager) CreateFeed(ctx context.Context, feed BuddyFeed) (*BuddyFeed, error) {
-	now := time.Now()
-	query := `
-		INSERT INTO buddy_feeds (
-			screen_name, feed_type, title, description, link,
-			published_at, created_at, updated_at, is_active
-		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
-		RETURNING id
-	`
-
-	var id int64
-	err := m.db.QueryRowContext(ctx, query,
-		feed.ScreenName, feed.FeedType, feed.Title, feed.Description, feed.Link,
-		feed.PublishedAt.Unix(), now.Unix(), now.Unix(), feed.IsActive,
-	).Scan(&id)
-
-	if err != nil {
-		return nil, fmt.Errorf("failed to create feed: %w", err)
-	}
-
-	feed.ID = id
-	feed.CreatedAt = now
-	feed.UpdatedAt = now
-
-	return &feed, nil
-}
-
-// GetUserFeed retrieves the feed configuration for a specific user.
-func (m *BuddyFeedManager) GetUserFeed(ctx context.Context, screenName string) (*BuddyFeed, error) {
-	var feed BuddyFeed
-	query := `
-		SELECT id, screen_name, feed_type, title, description, link,
-		       published_at, created_at, updated_at, is_active
-		FROM buddy_feeds
-		WHERE screen_name = ? AND is_active = 1
-		ORDER BY published_at DESC
-		LIMIT 1
-	`
-
-	err := m.db.QueryRowContext(ctx, query, screenName).Scan(
-		&feed.ID, &feed.ScreenName, &feed.FeedType, &feed.Title,
-		&feed.Description, &feed.Link, &feed.PublishedAt,
-		&feed.CreatedAt, &feed.UpdatedAt, &feed.IsActive,
-	)
-
-	if err == sql.ErrNoRows {
-		return nil, nil // No feed found
-	}
-	if err != nil {
-		return nil, fmt.Errorf("failed to get user feed: %w", err)
-	}
-
-	return &feed, nil
-}
-
-// GetBuddyListFeedItems retrieves aggregated feed items for a user's buddy list.
-func (m *BuddyFeedManager) GetBuddyListFeedItems(ctx context.Context, buddies []IdentScreenName, limit int) ([]BuddyFeedItem, error) {
-	if limit <= 0 {
-		limit = 100 // Default limit
-	}
-
-	if len(buddies) == 0 {
-		return []BuddyFeedItem{}, nil
-	}
-
-	// Build placeholders for IN clause
-	placeholders := make([]string, len(buddies))
-	args := make([]interface{}, len(buddies)+1)
-	for i, buddy := range buddies {
-		placeholders[i] = "?"
-		args[i] = buddy.String()
-	}
-	args[len(buddies)] = limit
-
-	// Query to get feed items from all buddies, sorted by published date
-	query := fmt.Sprintf(`
-		SELECT i.id, i.feed_id, i.title, i.description, i.link, i.guid,
-		       i.author, i.categories, i.published_at, i.created_at
-		FROM buddy_feed_items i
-		JOIN buddy_feeds f ON i.feed_id = f.id
-		WHERE f.screen_name IN (%s) AND f.is_active = 1
-		ORDER BY i.published_at DESC
-		LIMIT ?
-	`, strings.Join(placeholders, ","))
-
-	rows, err := m.db.QueryContext(ctx, query, args...)
-	if err != nil {
-		return nil, fmt.Errorf("failed to query buddy list feed items: %w", err)
-	}
-	defer rows.Close()
-
-	return m.scanFeedItems(rows)
-}
-
-// GetUserFeedItems retrieves feed items for a specific user.
-func (m *BuddyFeedManager) GetUserFeedItems(ctx context.Context, screenName string, limit int) ([]BuddyFeedItem, error) {
-	query := `
-		SELECT i.id, i.feed_id, i.title, i.description, i.link, i.guid,
-		       i.author, i.categories, i.published_at, i.created_at
-		FROM buddy_feed_items i
-		JOIN buddy_feeds f ON i.feed_id = f.id
-		WHERE f.screen_name = ? AND f.is_active = 1
-		ORDER BY i.published_at DESC
-		LIMIT ?
-	`
-
-	rows, err := m.db.QueryContext(ctx, query, screenName, limit)
-	if err != nil {
-		return nil, fmt.Errorf("failed to query feed items: %w", err)
-	}
-	defer rows.Close()
-
-	var items []BuddyFeedItem
-	for rows.Next() {
-		var item BuddyFeedItem
-		var categoriesJSON sql.NullString
-		var publishedAt, createdAt int64
-
-		err := rows.Scan(
-			&item.ID, &item.FeedID, &item.Title, &item.Description,
-			&item.Link, &item.GUID, &item.Author, &categoriesJSON,
-			&publishedAt, &createdAt,
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to scan feed item: %w", err)
-		}
-
-		item.PublishedAt = time.Unix(publishedAt, 0)
-		item.CreatedAt = time.Unix(createdAt, 0)
-
-		if categoriesJSON.Valid {
-			if err := json.Unmarshal([]byte(categoriesJSON.String), &item.Categories); err != nil {
-				return nil, fmt.Errorf("failed to unmarshal feed item categories: %w", err)
-			}
-		}
-
-		items = append(items, item)
-	}
-
-	return items, nil
-}
-
-// GetFeedItems retrieves items for a specific feed.
-func (m *BuddyFeedManager) GetFeedItems(ctx context.Context, feedID int64, limit int) ([]BuddyFeedItem, error) {
-	query := `
-		SELECT id, feed_id, title, description, link, guid,
-		       author, categories, published_at, created_at
-		FROM buddy_feed_items
-		WHERE feed_id = ?
-		ORDER BY published_at DESC
-		LIMIT ?
-	`
-
-	rows, err := m.db.QueryContext(ctx, query, feedID, limit)
-	if err != nil {
-		return nil, fmt.Errorf("failed to query feed items: %w", err)
-	}
-	defer rows.Close()
-
-	return m.scanFeedItems(rows)
-}
-
-// scanFeedItems is a helper to scan feed items from database rows.
-func (m *BuddyFeedManager) scanFeedItems(rows *sql.Rows) ([]BuddyFeedItem, error) {
-	var items []BuddyFeedItem
-	for rows.Next() {
-		var item BuddyFeedItem
-		var categoriesJSON sql.NullString
-		var publishedAt, createdAt int64
-
-		err := rows.Scan(
-			&item.ID, &item.FeedID, &item.Title, &item.Description,
-			&item.Link, &item.GUID, &item.Author, &categoriesJSON,
-			&publishedAt, &createdAt,
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to scan feed item: %w", err)
-		}
-
-		item.PublishedAt = time.Unix(publishedAt, 0)
-		item.CreatedAt = time.Unix(createdAt, 0)
-
-		if categoriesJSON.Valid {
-			if err := json.Unmarshal([]byte(categoriesJSON.String), &item.Categories); err != nil {
-				return nil, fmt.Errorf("failed to unmarshal feed item categories: %w", err)
-			}
-		}
-
-		items = append(items, item)
-	}
-
-	return items, nil
-}
-
-// AddFeedItem adds a new item to a feed.
-func (m *BuddyFeedManager) AddFeedItem(ctx context.Context, feedID int64, item BuddyFeedItem) (*BuddyFeedItem, error) {
-	categoriesJSON, _ := json.Marshal(item.Categories)
-	now := time.Now()
-
-	query := `
-		INSERT INTO buddy_feed_items (
-			feed_id, title, description, link, guid,
-			author, categories, published_at, created_at
-		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
-		RETURNING id
-	`
-
-	var id int64
-	err := m.db.QueryRowContext(ctx, query,
-		feedID, item.Title, item.Description, item.Link, item.GUID,
-		item.Author, string(categoriesJSON), item.PublishedAt.Unix(), now.Unix(),
-	).Scan(&id)
-
-	if err != nil {
-		return nil, fmt.Errorf("failed to add feed item: %w", err)
-	}
-
-	item.ID = id
-	item.FeedID = feedID
-	item.CreatedAt = now
-
-	// Update feed's updated_at timestamp
-	updateQuery := `UPDATE buddy_feeds SET updated_at = ? WHERE id = ?`
-	if _, err := m.db.ExecContext(ctx, updateQuery, now.Unix(), feedID); err != nil {
-		return nil, fmt.Errorf("failed to update feed timestamp: %w", err)
-	}
-
-	return &item, nil
-}
-
-// GetOrCreateFeedForUser gets an existing feed or creates a new one for a user.
-func (m *BuddyFeedManager) GetOrCreateFeedForUser(ctx context.Context, screenName string, feedType string) (int64, error) {
-	var feedID int64
-	query := `SELECT id FROM buddy_feeds WHERE screen_name = ? AND is_active = 1 LIMIT 1`
-	err := m.db.QueryRowContext(ctx, query, screenName).Scan(&feedID)
-
-	if err == nil {
-		return feedID, nil
-	}
-
-	if err != sql.ErrNoRows {
-		return 0, fmt.Errorf("failed to query feed: %w", err)
-	}
-
-	// Create new feed
-	if feedType == "" {
-		feedType = "status"
-	}
-
-	feed := BuddyFeed{
-		ScreenName:  screenName,
-		FeedType:    feedType,
-		Title:       fmt.Sprintf("%s's Feed", screenName),
-		Description: fmt.Sprintf("Updates from %s", screenName),
-		Link:        fmt.Sprintf("/buddyfeed/getUser?u=%s", screenName),
-		PublishedAt: time.Now(),
-		IsActive:    true,
-	}
-
-	createdFeed, err := m.CreateFeed(ctx, feed)
-	if err != nil {
-		return 0, fmt.Errorf("failed to create feed: %w", err)
-	}
-
-	return createdFeed.ID, nil
-}

+ 0 - 419
state/webapi_vanity.go

@@ -1,419 +0,0 @@
-package state
-
-import (
-	"context"
-	"database/sql"
-	"fmt"
-	"log/slog"
-	"regexp"
-	"strings"
-	"time"
-)
-
-// VanityURL represents a user's vanity URL configuration.
-type VanityURL struct {
-	ScreenName   string     `json:"screenName"`
-	VanityURL    string     `json:"vanityUrl"`
-	DisplayName  string     `json:"displayName,omitempty"`
-	Bio          string     `json:"bio,omitempty"`
-	Location     string     `json:"location,omitempty"`
-	Website      string     `json:"website,omitempty"`
-	CreatedAt    time.Time  `json:"createdAt"`
-	UpdatedAt    time.Time  `json:"updatedAt"`
-	IsActive     bool       `json:"isActive"`
-	ClickCount   int        `json:"clickCount"`
-	LastAccessed *time.Time `json:"lastAccessed,omitempty"`
-}
-
-// VanityURLRedirect represents a vanity URL access record.
-type VanityURLRedirect struct {
-	ID         int64     `json:"id"`
-	VanityURL  string    `json:"vanityUrl"`
-	AccessedAt time.Time `json:"accessedAt"`
-	IPAddress  string    `json:"ipAddress,omitempty"`
-	UserAgent  string    `json:"userAgent,omitempty"`
-	Referer    string    `json:"referer,omitempty"`
-}
-
-// VanityInfo represents the response for vanity URL lookups.
-type VanityInfo struct {
-	ScreenName  string                 `json:"screenName"`
-	VanityURL   string                 `json:"vanityUrl"`
-	DisplayName string                 `json:"displayName,omitempty"`
-	Bio         string                 `json:"bio,omitempty"`
-	Location    string                 `json:"location,omitempty"`
-	Website     string                 `json:"website,omitempty"`
-	ProfileURL  string                 `json:"profileUrl"`
-	IsActive    bool                   `json:"isActive"`
-	Extra       map[string]interface{} `json:"extra,omitempty"`
-}
-
-// VanityURLManager manages vanity URL operations.
-type VanityURLManager struct {
-	db       *sql.DB
-	logger   *slog.Logger
-	baseURL  string   // Base URL for the service (e.g., "https://aim.example.com")
-	reserved []string // Reserved URLs that cannot be claimed
-}
-
-// NewVanityURLManager creates a new vanity URL manager.
-func NewVanityURLManager(db *sql.DB, logger *slog.Logger, baseURL string) *VanityURLManager {
-	return &VanityURLManager{
-		db:      db,
-		logger:  logger,
-		baseURL: baseURL,
-		reserved: []string{
-			"api", "admin", "help", "support", "about", "terms", "privacy",
-			"login", "logout", "register", "signup", "signin", "settings",
-			"profile", "user", "users", "aim", "aol", "webapi", "oscar",
-			"chat", "im", "message", "buddy", "buddies", "feed", "rss",
-		},
-	}
-}
-
-// CreateOrUpdateVanityURL creates or updates a vanity URL for a user.
-func (m *VanityURLManager) CreateOrUpdateVanityURL(ctx context.Context, screenName string, vanityURL string, info map[string]interface{}) error {
-	// Validate vanity URL
-	if err := m.validateVanityURL(vanityURL); err != nil {
-		return err
-	}
-
-	// Check if URL is reserved
-	if m.isReserved(vanityURL) {
-		return fmt.Errorf("vanity URL '%s' is reserved", vanityURL)
-	}
-
-	// Extract optional fields from info
-	displayName, _ := info["displayName"].(string)
-	bio, _ := info["bio"].(string)
-	location, _ := info["location"].(string)
-	website, _ := info["website"].(string)
-
-	now := time.Now()
-
-	// Try to update existing record first
-	updateQuery := `
-		UPDATE vanity_urls
-		SET vanity_url = ?, display_name = ?, bio = ?, location = ?, 
-		    website = ?, updated_at = ?, is_active = ?
-		WHERE screen_name = ?
-	`
-
-	result, err := m.db.ExecContext(ctx, updateQuery,
-		vanityURL, displayName, bio, location, website,
-		now.Unix(), true, screenName,
-	)
-
-	if err != nil {
-		return fmt.Errorf("failed to update vanity URL: %w", err)
-	}
-
-	rowsAffected, _ := result.RowsAffected()
-	if rowsAffected > 0 {
-		m.logger.InfoContext(ctx, "updated vanity URL",
-			"screenName", screenName,
-			"vanityURL", vanityURL,
-		)
-		return nil
-	}
-
-	// Insert new record
-	insertQuery := `
-		INSERT INTO vanity_urls (
-			screen_name, vanity_url, display_name, bio, location,
-			website, created_at, updated_at, is_active, click_count
-		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-	`
-
-	_, err = m.db.ExecContext(ctx, insertQuery,
-		screenName, vanityURL, displayName, bio, location,
-		website, now.Unix(), now.Unix(), true, 0,
-	)
-
-	if err != nil {
-		if strings.Contains(err.Error(), "UNIQUE") {
-			return fmt.Errorf("vanity URL '%s' is already taken", vanityURL)
-		}
-		return fmt.Errorf("failed to create vanity URL: %w", err)
-	}
-
-	m.logger.InfoContext(ctx, "created vanity URL",
-		"screenName", screenName,
-		"vanityURL", vanityURL,
-	)
-
-	return nil
-}
-
-// GetVanityInfo retrieves vanity URL information.
-func (m *VanityURLManager) GetVanityInfo(ctx context.Context, vanityURL string) (*VanityInfo, error) {
-	// Clean the vanity URL
-	vanityURL = strings.ToLower(strings.TrimSpace(vanityURL))
-
-	query := `
-		SELECT screen_name, vanity_url, display_name, bio, location,
-		       website, created_at, updated_at, is_active, click_count, last_accessed
-		FROM vanity_urls
-		WHERE vanity_url = ? AND is_active = 1
-	`
-
-	var v VanityURL
-	var createdAt, updatedAt int64
-	var lastAccessed sql.NullInt64
-
-	err := m.db.QueryRowContext(ctx, query, vanityURL).Scan(
-		&v.ScreenName, &v.VanityURL, &v.DisplayName, &v.Bio, &v.Location,
-		&v.Website, &createdAt, &updatedAt, &v.IsActive, &v.ClickCount, &lastAccessed,
-	)
-
-	if err == sql.ErrNoRows {
-		return nil, fmt.Errorf("vanity URL not found: %s", vanityURL)
-	}
-	if err != nil {
-		return nil, fmt.Errorf("failed to get vanity info: %w", err)
-	}
-
-	v.CreatedAt = time.Unix(createdAt, 0)
-	v.UpdatedAt = time.Unix(updatedAt, 0)
-	if lastAccessed.Valid {
-		t := time.Unix(lastAccessed.Int64, 0)
-		v.LastAccessed = &t
-	}
-
-	// Create response info
-	info := &VanityInfo{
-		ScreenName:  v.ScreenName,
-		VanityURL:   v.VanityURL,
-		DisplayName: v.DisplayName,
-		Bio:         v.Bio,
-		Location:    v.Location,
-		Website:     v.Website,
-		ProfileURL:  m.buildProfileURL(v.VanityURL),
-		IsActive:    v.IsActive,
-		Extra: map[string]interface{}{
-			"createdAt":  v.CreatedAt.Unix(),
-			"clickCount": v.ClickCount,
-		},
-	}
-
-	// Update click count and last accessed asynchronously
-	go m.recordAccess(context.Background(), vanityURL)
-
-	return info, nil
-}
-
-// GetVanityInfoByScreenName retrieves vanity URL info by screen name.
-func (m *VanityURLManager) GetVanityInfoByScreenName(ctx context.Context, screenName string) (*VanityInfo, error) {
-	query := `
-		SELECT screen_name, vanity_url, display_name, bio, location,
-		       website, created_at, updated_at, is_active, click_count, last_accessed
-		FROM vanity_urls
-		WHERE screen_name = ? AND is_active = 1
-	`
-
-	var v VanityURL
-	var createdAt, updatedAt int64
-	var lastAccessed sql.NullInt64
-
-	err := m.db.QueryRowContext(ctx, query, screenName).Scan(
-		&v.ScreenName, &v.VanityURL, &v.DisplayName, &v.Bio, &v.Location,
-		&v.Website, &createdAt, &updatedAt, &v.IsActive, &v.ClickCount, &lastAccessed,
-	)
-
-	if err == sql.ErrNoRows {
-		return nil, nil // No vanity URL configured
-	}
-	if err != nil {
-		return nil, fmt.Errorf("failed to get vanity info: %w", err)
-	}
-
-	v.CreatedAt = time.Unix(createdAt, 0)
-	v.UpdatedAt = time.Unix(updatedAt, 0)
-	if lastAccessed.Valid {
-		t := time.Unix(lastAccessed.Int64, 0)
-		v.LastAccessed = &t
-	}
-
-	// Create response info
-	info := &VanityInfo{
-		ScreenName:  v.ScreenName,
-		VanityURL:   v.VanityURL,
-		DisplayName: v.DisplayName,
-		Bio:         v.Bio,
-		Location:    v.Location,
-		Website:     v.Website,
-		ProfileURL:  m.buildProfileURL(v.VanityURL),
-		IsActive:    v.IsActive,
-		Extra: map[string]interface{}{
-			"createdAt":  v.CreatedAt.Unix(),
-			"clickCount": v.ClickCount,
-		},
-	}
-
-	return info, nil
-}
-
-// DeleteVanityURL removes a user's vanity URL.
-func (m *VanityURLManager) DeleteVanityURL(ctx context.Context, screenName string) error {
-	query := `UPDATE vanity_urls SET is_active = 0, updated_at = ? WHERE screen_name = ?`
-
-	_, err := m.db.ExecContext(ctx, query, time.Now().Unix(), screenName)
-	if err != nil {
-		return fmt.Errorf("failed to delete vanity URL: %w", err)
-	}
-
-	m.logger.InfoContext(ctx, "deleted vanity URL", "screenName", screenName)
-	return nil
-}
-
-// CheckAvailability checks if a vanity URL is available.
-func (m *VanityURLManager) CheckAvailability(ctx context.Context, vanityURL string) (bool, error) {
-	// Validate format
-	if err := m.validateVanityURL(vanityURL); err != nil {
-		return false, err
-	}
-
-	// Check if reserved
-	if m.isReserved(vanityURL) {
-		return false, nil
-	}
-
-	// Check database
-	query := `SELECT COUNT(*) FROM vanity_urls WHERE vanity_url = ? AND is_active = 1`
-
-	var count int
-	err := m.db.QueryRowContext(ctx, query, vanityURL).Scan(&count)
-	if err != nil {
-		return false, fmt.Errorf("failed to check availability: %w", err)
-	}
-
-	return count == 0, nil
-}
-
-// GetPopularVanityURLs retrieves the most accessed vanity URLs.
-func (m *VanityURLManager) GetPopularVanityURLs(ctx context.Context, limit int) ([]VanityInfo, error) {
-	query := `
-		SELECT screen_name, vanity_url, display_name, bio, location,
-		       website, is_active, click_count
-		FROM vanity_urls
-		WHERE is_active = 1
-		ORDER BY click_count DESC
-		LIMIT ?
-	`
-
-	rows, err := m.db.QueryContext(ctx, query, limit)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get popular vanity URLs: %w", err)
-	}
-	defer rows.Close()
-
-	var results []VanityInfo
-	for rows.Next() {
-		var info VanityInfo
-		var displayName, bio, location, website sql.NullString
-
-		err := rows.Scan(
-			&info.ScreenName, &info.VanityURL, &displayName, &bio,
-			&location, &website, &info.IsActive, &info.Extra,
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to scan vanity info: %w", err)
-		}
-
-		if displayName.Valid {
-			info.DisplayName = displayName.String
-		}
-		if bio.Valid {
-			info.Bio = bio.String
-		}
-		if location.Valid {
-			info.Location = location.String
-		}
-		if website.Valid {
-			info.Website = website.String
-		}
-
-		info.ProfileURL = m.buildProfileURL(info.VanityURL)
-		results = append(results, info)
-	}
-
-	return results, nil
-}
-
-// recordAccess records a vanity URL access.
-func (m *VanityURLManager) recordAccess(ctx context.Context, vanityURL string) {
-	// Update click count and last accessed time
-	updateQuery := `
-		UPDATE vanity_urls
-		SET click_count = click_count + 1, last_accessed = ?
-		WHERE vanity_url = ?
-	`
-
-	_, err := m.db.ExecContext(ctx, updateQuery, time.Now().Unix(), vanityURL)
-	if err != nil {
-		m.logger.Error("failed to record vanity URL access", "error", err, "vanityURL", vanityURL)
-	}
-}
-
-// LogRedirect logs a vanity URL redirect for analytics.
-func (m *VanityURLManager) LogRedirect(ctx context.Context, redirect VanityURLRedirect) error {
-	query := `
-		INSERT INTO vanity_url_redirects (vanity_url, accessed_at, ip_address, user_agent, referer)
-		VALUES (?, ?, ?, ?, ?)
-	`
-
-	_, err := m.db.ExecContext(ctx, query,
-		redirect.VanityURL, redirect.AccessedAt.Unix(),
-		redirect.IPAddress, redirect.UserAgent, redirect.Referer,
-	)
-
-	if err != nil {
-		return fmt.Errorf("failed to log redirect: %w", err)
-	}
-
-	return nil
-}
-
-// validateVanityURL validates the format of a vanity URL.
-func (m *VanityURLManager) validateVanityURL(vanityURL string) error {
-	// Clean and lowercase
-	vanityURL = strings.ToLower(strings.TrimSpace(vanityURL))
-
-	// Check length
-	if len(vanityURL) < 3 || len(vanityURL) > 30 {
-		return fmt.Errorf("vanity URL must be between 3 and 30 characters")
-	}
-
-	// Check format (alphanumeric, hyphens, underscores only)
-	validFormat := regexp.MustCompile(`^[a-z0-9_-]+$`)
-	if !validFormat.MatchString(vanityURL) {
-		return fmt.Errorf("vanity URL can only contain letters, numbers, hyphens, and underscores")
-	}
-
-	// Can't start or end with special characters
-	if strings.HasPrefix(vanityURL, "-") || strings.HasPrefix(vanityURL, "_") ||
-		strings.HasSuffix(vanityURL, "-") || strings.HasSuffix(vanityURL, "_") {
-		return fmt.Errorf("vanity URL cannot start or end with hyphens or underscores")
-	}
-
-	return nil
-}
-
-// isReserved checks if a vanity URL is in the reserved list.
-func (m *VanityURLManager) isReserved(vanityURL string) bool {
-	vanityURL = strings.ToLower(vanityURL)
-	for _, reserved := range m.reserved {
-		if vanityURL == reserved {
-			return true
-		}
-	}
-	return false
-}
-
-// buildProfileURL builds the full profile URL for a vanity URL.
-func (m *VanityURLManager) buildProfileURL(vanityURL string) string {
-	if m.baseURL == "" {
-		return fmt.Sprintf("/profile/%s", vanityURL)
-	}
-	return fmt.Sprintf("%s/profile/%s", strings.TrimRight(m.baseURL, "/"), vanityURL)
-}