4
0

buddyfeed.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. package handlers
  2. import (
  3. "encoding/xml"
  4. "fmt"
  5. "log/slog"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "github.com/mk6i/open-oscar-server/state"
  11. )
  12. // BuddyFeedHandler handles Web AIM API buddy feed endpoints.
  13. type BuddyFeedHandler struct {
  14. SessionManager *state.WebAPISessionManager
  15. FeedManager *state.BuddyFeedManager
  16. SessionRetriever SessionRetriever
  17. Logger *slog.Logger
  18. }
  19. // GetUser handles GET /buddyfeed/getUser requests to retrieve a user's feed.
  20. func (h *BuddyFeedHandler) GetUser(w http.ResponseWriter, r *http.Request) {
  21. ctx := r.Context()
  22. // Get target user from 't' parameter as per spec
  23. targetUser := r.URL.Query().Get("t")
  24. if targetUser == "" {
  25. SendError(w, http.StatusBadRequest, "missing 't' parameter")
  26. return
  27. }
  28. // Get format parameter
  29. format := strings.ToLower(r.URL.Query().Get("f"))
  30. h.Logger.DebugContext(ctx, "retrieving user feed",
  31. "user", targetUser,
  32. "format", format,
  33. )
  34. // Get the feed configuration
  35. feed, err := h.FeedManager.GetUserFeed(ctx, targetUser)
  36. if err != nil {
  37. h.Logger.ErrorContext(ctx, "failed to get user feed",
  38. "user", targetUser,
  39. "error", err,
  40. )
  41. SendError(w, http.StatusInternalServerError, "failed to retrieve feed")
  42. return
  43. }
  44. // Build feed response
  45. var feedResponse *FeedResponse
  46. if feed == nil {
  47. // No feed configured - generate empty feed
  48. feedResponse = GenerateEmptyFeed(targetUser)
  49. } else {
  50. // Get feed items
  51. items, err := h.FeedManager.GetFeedItems(ctx, feed.ID, 50)
  52. if err != nil {
  53. h.Logger.ErrorContext(ctx, "failed to get feed items",
  54. "feedID", feed.ID,
  55. "error", err,
  56. )
  57. SendError(w, http.StatusInternalServerError, "failed to retrieve feed items")
  58. return
  59. }
  60. feedResponse = &FeedResponse{
  61. Feed: *feed,
  62. Items: items,
  63. }
  64. }
  65. // Send response based on format
  66. switch format {
  67. case "atom":
  68. h.sendAtomFeed(w, feedResponse.ToAtom())
  69. case "json":
  70. response := BaseResponse{}
  71. response.Response.StatusCode = 200
  72. response.Response.StatusText = "OK"
  73. response.Response.Data = feedResponse.ToJSON()
  74. SendResponse(w, r, response, h.Logger)
  75. case "rss", "native", "":
  76. h.sendRSSFeed(w, feedResponse.ToRSS())
  77. default:
  78. h.sendRSSFeed(w, feedResponse.ToRSS())
  79. }
  80. }
  81. // GetBuddylist handles GET /buddyfeed/getBuddylist requests to retrieve aggregated buddy feeds.
  82. func (h *BuddyFeedHandler) GetBuddylist(w http.ResponseWriter, r *http.Request) {
  83. ctx := r.Context()
  84. // Authentication required for buddy list feed
  85. aimsid := r.URL.Query().Get("aimsid")
  86. if aimsid == "" {
  87. SendError(w, http.StatusBadRequest, "missing aimsid parameter")
  88. return
  89. }
  90. // Get session
  91. session, err := h.SessionManager.GetSession(r.Context(), aimsid)
  92. if err != nil {
  93. SendError(w, http.StatusUnauthorized, "invalid or expired session")
  94. return
  95. }
  96. // Update session activity
  97. if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
  98. h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
  99. }
  100. // Get format and limit parameters
  101. format := strings.ToLower(r.URL.Query().Get("f"))
  102. if format == "" {
  103. format = "rss"
  104. }
  105. limit := 100 // Default limit
  106. if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
  107. if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
  108. limit = l
  109. if limit > 500 {
  110. limit = 500 // Max limit
  111. }
  112. }
  113. }
  114. h.Logger.DebugContext(ctx, "retrieving buddy list feed",
  115. "screenName", session.ScreenName.String(),
  116. "format", format,
  117. "limit", limit,
  118. )
  119. // Get buddy list from OSCAR session if available
  120. var buddies []state.IdentScreenName
  121. // Return empty feed for now - buddy list integration pending
  122. if len(buddies) == 0 {
  123. h.Logger.InfoContext(ctx, "no buddies found for feed aggregation",
  124. "screenName", session.ScreenName.String(),
  125. )
  126. // Return empty feed
  127. emptyFeed := map[string]interface{}{
  128. "title": fmt.Sprintf("%s's Buddy Feed", session.ScreenName.String()),
  129. "description": "Aggregated feed from your buddy list",
  130. "items": []interface{}{},
  131. }
  132. if format == "json" {
  133. response := BaseResponse{}
  134. response.Response.StatusCode = 200
  135. response.Response.StatusText = "OK"
  136. response.Response.Data = emptyFeed
  137. SendResponse(w, r, response, h.Logger)
  138. } else {
  139. h.sendEmptyRSSFeed(w, session.ScreenName.String())
  140. }
  141. return
  142. }
  143. // Get aggregated feed items
  144. items, err := h.FeedManager.GetBuddyListFeedItems(ctx, buddies, limit)
  145. if err != nil {
  146. h.Logger.ErrorContext(ctx, "failed to get buddy list feed",
  147. "screenName", session.ScreenName.String(),
  148. "error", err,
  149. )
  150. SendError(w, http.StatusInternalServerError, "failed to retrieve feed")
  151. return
  152. }
  153. // Build aggregated feed response
  154. feedResponse := &FeedResponse{
  155. Feed: state.BuddyFeed{
  156. Title: "Buddy List Feed",
  157. Description: "Aggregated feed from your buddy list",
  158. Link: "/buddyfeed/getBuddylist",
  159. PublishedAt: time.Now(),
  160. UpdatedAt: time.Now(),
  161. },
  162. Items: items,
  163. }
  164. // Send response based on format
  165. switch format {
  166. case "atom":
  167. h.sendAtomFeed(w, feedResponse.ToAtom())
  168. case "json":
  169. response := BaseResponse{}
  170. response.Response.StatusCode = 200
  171. response.Response.StatusText = "OK"
  172. response.Response.Data = feedResponse.ToJSON()
  173. SendResponse(w, r, response, h.Logger)
  174. case "rss", "":
  175. h.sendRSSFeed(w, feedResponse.ToRSS())
  176. default:
  177. h.sendRSSFeed(w, feedResponse.ToRSS())
  178. }
  179. }
  180. // PushFeed handles GET /buddyfeed/pushFeed requests to submit feed updates.
  181. func (h *BuddyFeedHandler) PushFeed(w http.ResponseWriter, r *http.Request) {
  182. ctx := r.Context()
  183. // Get authentication token or session
  184. token := r.URL.Query().Get("a")
  185. aimsid := r.URL.Query().Get("aimsid")
  186. if token == "" && aimsid == "" {
  187. SendError(w, http.StatusBadRequest, "authentication required")
  188. return
  189. }
  190. var screenName string
  191. if aimsid != "" {
  192. session, err := h.SessionManager.GetSession(r.Context(), aimsid)
  193. if err != nil {
  194. SendError(w, http.StatusUnauthorized, "invalid or expired session")
  195. return
  196. }
  197. screenName = session.ScreenName.String()
  198. if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
  199. h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
  200. }
  201. } else {
  202. // Extract screen name from token authentication
  203. screenName = r.URL.Query().Get("s")
  204. if screenName == "" {
  205. SendError(w, http.StatusBadRequest, "missing source user")
  206. return
  207. }
  208. }
  209. // Extract feed parameters as per spec
  210. feedTitle := r.URL.Query().Get("feedTitle")
  211. feedLink := r.URL.Query().Get("feedLink")
  212. feedDesc := r.URL.Query().Get("feedDesc")
  213. itemTitle := r.URL.Query().Get("itemTitle")
  214. itemLink := r.URL.Query().Get("itemLink")
  215. itemGuid := r.URL.Query().Get("itemGuid")
  216. // Validate required parameters
  217. if feedTitle == "" || feedLink == "" || feedDesc == "" ||
  218. itemTitle == "" || itemLink == "" || itemGuid == "" {
  219. SendError(w, http.StatusBadRequest, "missing required feed parameters")
  220. return
  221. }
  222. h.Logger.InfoContext(ctx, "pushing feed update",
  223. "screenName", screenName,
  224. "itemTitle", itemTitle,
  225. )
  226. // Get or create feed for user
  227. feedID, err := h.FeedManager.GetOrCreateFeedForUser(ctx, screenName, "status")
  228. if err != nil {
  229. h.Logger.ErrorContext(ctx, "failed to get/create feed",
  230. "screenName", screenName,
  231. "error", err,
  232. )
  233. SendError(w, http.StatusInternalServerError, "failed to get/create feed")
  234. return
  235. }
  236. // Build feed item
  237. item := state.BuddyFeedItem{
  238. Title: itemTitle,
  239. Description: r.URL.Query().Get("itemDesc"),
  240. Link: itemLink,
  241. GUID: itemGuid,
  242. Author: screenName,
  243. PublishedAt: time.Now(),
  244. }
  245. // Add category if provided
  246. if category := r.URL.Query().Get("itemCategory"); category != "" {
  247. item.Categories = []string{category}
  248. }
  249. // Add the feed item
  250. if _, err := h.FeedManager.AddFeedItem(ctx, feedID, item); err != nil {
  251. h.Logger.ErrorContext(ctx, "failed to add feed item",
  252. "screenName", screenName,
  253. "feedID", feedID,
  254. "error", err,
  255. )
  256. SendError(w, http.StatusInternalServerError, "failed to add feed item")
  257. return
  258. }
  259. // Send success response
  260. response := BaseResponse{}
  261. response.Response.StatusCode = 200
  262. response.Response.StatusText = "OK"
  263. response.Response.Data = map[string]interface{}{
  264. "success": true,
  265. }
  266. SendResponse(w, r, response, h.Logger)
  267. }
  268. // sendRSSFeed sends an RSS feed response.
  269. func (h *BuddyFeedHandler) sendRSSFeed(w http.ResponseWriter, feed *RSSFeed) {
  270. w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
  271. // Add XML declaration
  272. w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
  273. // Marshal and write the feed
  274. encoder := xml.NewEncoder(w)
  275. encoder.Indent("", " ")
  276. if err := encoder.Encode(feed); err != nil {
  277. h.Logger.Error("failed to encode RSS feed", "error", err)
  278. }
  279. }
  280. // sendAtomFeed sends an Atom feed response.
  281. func (h *BuddyFeedHandler) sendAtomFeed(w http.ResponseWriter, feed *AtomFeed) {
  282. w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
  283. // Add XML declaration
  284. w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
  285. // Marshal and write the feed
  286. encoder := xml.NewEncoder(w)
  287. encoder.Indent("", " ")
  288. if err := encoder.Encode(feed); err != nil {
  289. h.Logger.Error("failed to encode Atom feed", "error", err)
  290. }
  291. }
  292. // sendEmptyRSSFeed sends an empty RSS feed.
  293. func (h *BuddyFeedHandler) sendEmptyRSSFeed(w http.ResponseWriter, screenName string) {
  294. w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
  295. emptyFeed := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
  296. <rss version="2.0">
  297. <channel>
  298. <title>%s's Buddy Feed</title>
  299. <description>Aggregated feed from your buddy list</description>
  300. <link>/buddyfeed/getBuddylist</link>
  301. <language>en-US</language>
  302. </channel>
  303. </rss>`, screenName)
  304. w.Write([]byte(emptyFeed))
  305. }