diff --git a/alby/alby_service.go b/alby/alby_service.go index 04e9f7edf..ba95be7f8 100644 --- a/alby/alby_service.go +++ b/alby/alby_service.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/getAlby/hub/config" @@ -208,3 +209,63 @@ func (svc *albyService) GetInfo(ctx context.Context) (*AlbyInfo, error) { Incidents: incidents, }, nil } + +func (svc *albyService) GetLatestBlogPost(ctx context.Context) (*BlogPost, error) { + client := &http.Client{Timeout: 10 * time.Second} + + url := fmt.Sprintf("%s/hub/blog/latest", albyInternalAPIURL) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + logger.Logger.WithError(err).Error("Error creating request to blog endpoint") + return nil, err + } + setDefaultRequestHeaders(req) + + res, err := client.Do(req) + if err != nil { + logger.Logger.WithError(err).Error("Failed to fetch blog endpoint") + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + logger.Logger.WithError(err).Error("Failed to read response body") + return nil, errors.New("failed to read response body") + } + + if res.StatusCode >= 300 { + logger.Logger.WithFields(logrus.Fields{ + "body": string(body), + "status_code": res.StatusCode, + }).Error("Blog endpoint returned non-success code") + return nil, fmt.Errorf("blog endpoint returned non-success code: %s", string(body)) + } + + var post struct { + ID string `json:"id"` + Title string `json:"title"` + Lead string `json:"lead"` + URL string `json:"url"` + ImageURL string `json:"imageUrl"` + PublishedAt string `json:"publishedAt"` + } + err = json.Unmarshal(body, &post) + if err != nil { + logger.Logger.WithError(err).Error("Failed to decode blog API response") + return nil, err + } + + if post.Title == "" || post.URL == "" { + return nil, errors.New("no blog post found") + } + + return &BlogPost{ + ID: post.ID, + Title: post.Title, + Description: post.Lead, + URL: post.URL, + ImageURL: strings.ReplaceAll(post.ImageURL, "&", "&"), + }, nil +} diff --git a/alby/models.go b/alby/models.go index e366050aa..4f7fcccb6 100644 --- a/alby/models.go +++ b/alby/models.go @@ -11,6 +11,15 @@ type AlbyService interface { GetInfo(ctx context.Context) (*AlbyInfo, error) GetBitcoinRate(ctx context.Context) (*BitcoinRate, error) GetChannelPeerSuggestions(ctx context.Context) ([]ChannelPeerSuggestion, error) + GetLatestBlogPost(ctx context.Context) (*BlogPost, error) +} + +type BlogPost struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + ImageURL string `json:"imageUrl,omitempty"` } type AlbyOAuthService interface { diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 4bc19c193..bc4cf7d48 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,2 +1,5 @@ # set a custom messageboard wallet (should be a sub-wallet with only make invoice and list transactions permissions) -#VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894" \ No newline at end of file +#VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894" + +# optional blog endpoint used by Home Alby Blog widget +#VITE_ALBY_BLOG_ENDPOINT=https://getalby.com/api/hub/blog/latest \ No newline at end of file diff --git a/frontend/src/components/home/widgets/AlbyBlogWidget.tsx b/frontend/src/components/home/widgets/AlbyBlogWidget.tsx new file mode 100644 index 000000000..f5f3a7f45 --- /dev/null +++ b/frontend/src/components/home/widgets/AlbyBlogWidget.tsx @@ -0,0 +1,72 @@ +import { SquareArrowOutUpRightIcon } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; +import { useAlbyBlog } from "src/hooks/useAlbyBlog"; +import { cn } from "src/lib/utils"; + +const fallbackThemes = [ + "from-emerald-200 via-cyan-200 to-yellow-200", + "from-orange-200 via-amber-100 to-pink-100", + "from-slate-200 via-zinc-100 to-lime-100", + "from-sky-200 via-indigo-100 to-violet-100", +]; + +function hashString(str: string): number { + return Math.abs([...str].reduce((sum, ch) => sum + ch.charCodeAt(0), 0)); +} + +export function AlbyBlogWidget() { + const { data: post } = useAlbyBlog(); + + if (!post) { + return null; + } + + const theme = fallbackThemes[hashString(post.id) % fallbackThemes.length]; + + return ( + + + Alby Blog + + +
+ {post.imageUrl ? ( + {post.title} + ) : ( +
+
+
+
+
+ )} +
+ + +
+ {post.title} + + {post.description} + +
+
+ + Read on Alby Blog + + +
+
+ + ); +} diff --git a/frontend/src/hooks/useAlbyBlog.ts b/frontend/src/hooks/useAlbyBlog.ts new file mode 100644 index 000000000..7fbac7570 --- /dev/null +++ b/frontend/src/hooks/useAlbyBlog.ts @@ -0,0 +1,17 @@ +import useSWR from "swr"; + +import { swrFetcher } from "src/utils/swr"; + +type BlogPost = { + id: string; + title: string; + description: string; + url: string; + imageUrl?: string; +}; + +export function useAlbyBlog() { + return useSWR("/api/alby/blog/latest", swrFetcher, { + dedupingInterval: 5 * 60 * 1000, // 5 minutes + }); +} diff --git a/frontend/src/screens/Home.tsx b/frontend/src/screens/Home.tsx index 3ce7a6a67..567dbecdd 100644 --- a/frontend/src/screens/Home.tsx +++ b/frontend/src/screens/Home.tsx @@ -22,6 +22,7 @@ import React from "react"; import albyGo from "src/assets/suggested-apps/alby-go.png"; import zapplanner from "src/assets/suggested-apps/zapplanner.png"; import { AppOfTheDayWidget } from "src/components/home/widgets/AppOfTheDayWidget"; +import { AlbyBlogWidget } from "src/components/home/widgets/AlbyBlogWidget"; import { BlockHeightWidget } from "src/components/home/widgets/BlockHeightWidget"; import { ForwardsWidget } from "src/components/home/widgets/ForwardsWidget"; import { LatestUsedAppsWidget } from "src/components/home/widgets/LatestUsedAppsWidget"; @@ -193,6 +194,8 @@ function Home() { + +
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 42589f100..37eec3d3a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -98,7 +98,7 @@ const insertDevCSPPlugin: Plugin = { "", ` - ` + ` ); }, }, diff --git a/http/alby_http_service.go b/http/alby_http_service.go index 1c88adebc..8dd24f17a 100644 --- a/http/alby_http_service.go +++ b/http/alby_http_service.go @@ -32,6 +32,7 @@ func (albyHttpSvc *AlbyHttpService) RegisterSharedRoutes(readOnlyApiGroup *echo. e.GET("/api/alby/callback", albyHttpSvc.albyCallbackHandler) e.GET("/api/alby/info", albyHttpSvc.albyInfoHandler) e.GET("/api/alby/rates", albyHttpSvc.albyBitcoinRateHandler) + e.GET("/api/alby/blog/latest", albyHttpSvc.albyBlogLatestHandler) readOnlyApiGroup.GET("/alby/me", albyHttpSvc.albyMeHandler) fullAccessApiGroup.POST("/alby/link-account", albyHttpSvc.albyLinkAccountHandler) fullAccessApiGroup.POST("/alby/auto-channel", albyHttpSvc.autoChannelHandler) @@ -96,6 +97,17 @@ func (albyHttpSvc *AlbyHttpService) albyBitcoinRateHandler(c echo.Context) error return c.JSON(http.StatusOK, rate) } +func (albyHttpSvc *AlbyHttpService) albyBlogLatestHandler(c echo.Context) error { + post, err := albyHttpSvc.albySvc.GetLatestBlogPost(c.Request().Context()) + if err != nil { + logger.Logger.WithError(err).Error("Failed to get latest blog post") + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: fmt.Sprintf("Failed to get latest blog post: %s", err.Error()), + }) + } + return c.JSON(http.StatusOK, post) +} + func (albyHttpSvc *AlbyHttpService) albyCallbackHandler(c echo.Context) error { code := c.QueryParam("code") diff --git a/http/http_service.go b/http/http_service.go index 7d798bd5d..3d292b194 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -66,7 +66,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ ContentTypeNosniff: "nosniff", XFrameOptions: "DENY", - ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://uploads.getalby-assets.com https://getalby.com; connect-src 'self' https://api.getalby.com https://getalby.com https://zapplanner.albylabs.com wss://relay.getalby.com wss://relay2.getalby.com; frame-src https://embed.bitrefill.com", + ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://uploads.getalby-assets.com https://getalby.com https://framerusercontent.com; connect-src 'self' https://api.getalby.com https://getalby.com https://zapplanner.albylabs.com wss://relay.getalby.com wss://relay2.getalby.com; frame-src https://embed.bitrefill.com", ReferrerPolicy: "no-referrer", })) e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ diff --git a/tests/mocks/AlbyService.go b/tests/mocks/AlbyService.go index d4a127949..94bbca8a9 100644 --- a/tests/mocks/AlbyService.go +++ b/tests/mocks/AlbyService.go @@ -162,6 +162,67 @@ func (_c *MockAlbyService_GetChannelPeerSuggestions_Call) RunAndReturn(run func( return _c } +func (_mock *MockAlbyService) GetLatestBlogPost(ctx context.Context) (*alby.BlogPost, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetLatestBlogPost") + } + + var r0 *alby.BlogPost + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (*alby.BlogPost, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) *alby.BlogPost); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*alby.BlogPost) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockAlbyService_GetLatestBlogPost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestBlogPost' +type MockAlbyService_GetLatestBlogPost_Call struct { + *mock.Call +} + +// GetLatestBlogPost is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockAlbyService_Expecter) GetLatestBlogPost(ctx interface{}) *MockAlbyService_GetLatestBlogPost_Call { + return &MockAlbyService_GetLatestBlogPost_Call{Call: _e.mock.On("GetLatestBlogPost", ctx)} +} + +func (_c *MockAlbyService_GetLatestBlogPost_Call) Run(run func(ctx context.Context)) *MockAlbyService_GetLatestBlogPost_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockAlbyService_GetLatestBlogPost_Call) Return(blogPost *alby.BlogPost, err error) *MockAlbyService_GetLatestBlogPost_Call { + _c.Call.Return(blogPost, err) + return _c +} + +func (_c *MockAlbyService_GetLatestBlogPost_Call) RunAndReturn(run func(ctx context.Context) (*alby.BlogPost, error)) *MockAlbyService_GetLatestBlogPost_Call { + _c.Call.Return(run) + return _c +} + // GetInfo provides a mock function for the type MockAlbyService func (_mock *MockAlbyService) GetInfo(ctx context.Context) (*alby.AlbyInfo, error) { ret := _mock.Called(ctx) diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index f96697de6..8005a5062 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -409,6 +409,17 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string return WailsRequestRouterResponse{Body: nil, Error: err.Error()} } return WailsRequestRouterResponse{Body: rate, Error: ""} + case "/api/alby/blog/latest": + post, err := app.svc.GetAlbySvc().GetLatestBlogPost(ctx) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "route": route, + "method": method, + "body": body, + }).WithError(err).Error("Failed to get latest blog post") + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + return WailsRequestRouterResponse{Body: post, Error: ""} case "/api/apps": switch method { case "POST":