alby-hub/http/alby_http_service.go
saunter 23dccc6c6f
feat: stories (#2172)
* feat: integrate Stories widget with backend endpoint

Add stories endpoint plumbing for HTTP and Wails, wire the Home Stories card
to fetch from /api/alby/stories, and keep it first in the right column.

Made-with: Cursor

* feat(home): story modal CTAs and preview fallback

- Add contextual actions in the story dialog (update hub with version,
  open Alby Go in-app, install extension) keyed by kind or title
- Use preview stories when the stories API request fails
- Pass hub version from useInfo into the update link

Made-with: Cursor

* feat(stories): polish modal, drop preview fallback

- Widen modal and put video edge-to-edge with overlay close button
- Drop verbose header and 'Watch on YouTube' button
- Remove previewStories fallback so widget hides until upstream API ships
- Tighten title line-height

* feat(stories): render cta from API instead of mapping by kind

Move CTA copy and URLs into the API response. Hub renders story.cta
directly, so adding new story types no longer requires a hub release.

* chore(csp): allow cdn.getalby-assets.com in img-src

* feat(stories): bump avatar size and add ring gap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): post-review cleanups

- Use react-router Link for in-tab CTA instead of plain <a>.
- Drop redundant www.youtube.com from frame-src (embeds always go through nocookie).
- Tighten stories endpoint status check from >= 300 to >= 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): address CodeRabbit feedback

- Switch StoriesWidget to useSWR + swrFetcher (project convention).
- Guard story iframe with isYouTubeUrl so non-YouTube urls never embed.
- Wrap GetStories errors with fmt.Errorf("...: %w", err).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): drop isYouTubeUrl guard

Stories are curated and always YouTube; the runtime check was
redundant. CSP frame-src still constrains the iframe source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): drop getYouTubeEmbedUrl, embed videoUrl as-is

The Alby API now sends canonical youtube-nocookie embed URLs with
autoplay/rel query strings (getAlby/getalby.com#2568), so the
runtime normalization is no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): use w-16 instead of arbitrary w-[73px]

Match the avatar's size token; no magic numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): take videoId from API and assemble embed url locally

Pairs with getAlby/getalby.com#2568. The API now sends just the
YouTube videoId; the hub composes the canonical embed URL so the
domain/query-string format stays in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): treat 3xx as non-success, matching file convention

The other status checks in alby_oauth_service.go all use >= 300;
align GetStories so redirects don't slip through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): move viewed-storage key to constants, widen story button

Address review feedback:
- Centralize the localStorage key for viewed stories in localStorageKeys
  alongside the other keys.
- Widen the story button from w-16 to w-20 so "Alby Extension" fits on
  one line and matches the other titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): bump story button to w-24 so titles fit one line

w-20 still wrapped "Alby Extension"; w-24 fits all current titles
without truncation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "chore(stories): bump story button to w-24 so titles fit one line"

This reverts commit 0b47438f50.

* chore(stories): split title words onto separate lines

Reserve two lines for every story title so avatars align regardless of
title length.

* chore(stories): align homeStoriesViewed key with sibling pattern

* chore(stories): fit titles on one line

* chore(stories): widen story button to w-21 for one-line titles

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 10:50:15 +02:00

178 lines
6 KiB
Go

package http
import (
"errors"
"fmt"
"net/http"
"github.com/getAlby/hub/alby"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/service"
"github.com/labstack/echo/v4"
)
type AlbyHttpService struct {
albySvc alby.AlbyService
albyOAuthSvc alby.AlbyOAuthService
appConfig *config.AppConfig
svc service.Service
}
func NewAlbyHttpService(svc service.Service, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, appConfig *config.AppConfig) *AlbyHttpService {
return &AlbyHttpService{
albySvc: albySvc,
albyOAuthSvc: albyOAuthSvc,
appConfig: appConfig,
svc: svc,
}
}
func (albyHttpSvc *AlbyHttpService) RegisterSharedRoutes(readOnlyApiGroup *echo.Group, fullAccessApiGroup *echo.Group, e *echo.Echo) {
e.GET("/api/alby/callback", albyHttpSvc.albyCallbackHandler)
e.GET("/api/alby/info", albyHttpSvc.albyInfoHandler)
e.GET("/api/alby/rates/:currency", albyHttpSvc.albyBitcoinRateHandler)
e.GET("/api/alby/currencies", albyHttpSvc.albyCurrenciesHandler)
e.GET("/api/alby/stories", albyHttpSvc.albyStoriesHandler)
readOnlyApiGroup.GET("/alby/me", albyHttpSvc.albyMeHandler)
fullAccessApiGroup.POST("/alby/link-account", albyHttpSvc.albyLinkAccountHandler)
fullAccessApiGroup.POST("/alby/auto-channel", albyHttpSvc.autoChannelHandler)
fullAccessApiGroup.POST("/alby/unlink-account", albyHttpSvc.unlinkHandler)
}
func (albyHttpSvc *AlbyHttpService) autoChannelHandler(c echo.Context) error {
ctx := c.Request().Context()
var autoChannelRequest alby.AutoChannelRequest
if err := c.Bind(&autoChannelRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}
autoChannelResponseResponse, err := albyHttpSvc.albyOAuthSvc.RequestAutoChannel(ctx, albyHttpSvc.svc.GetLNClient(), autoChannelRequest.IsPublic)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to request auto channel: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, autoChannelResponseResponse)
}
func (albyHttpSvc *AlbyHttpService) unlinkHandler(c echo.Context) error {
ctx := c.Request().Context()
err := albyHttpSvc.albyOAuthSvc.UnlinkAccount(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to unlink: %s", err.Error()),
})
}
return c.NoContent(http.StatusNoContent)
}
func (albyHttpSvc *AlbyHttpService) albyInfoHandler(c echo.Context) error {
info, err := albyHttpSvc.albySvc.GetInfo(c.Request().Context())
if err != nil {
logger.Logger.WithError(err).Error("Failed to request alby info endpoint")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to request alby info endpoint: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, info)
}
func (albyHttpSvc *AlbyHttpService) albyBitcoinRateHandler(c echo.Context) error {
rate, err := albyHttpSvc.albySvc.GetBitcoinRate(c.Request().Context(), c.Param("currency"))
if err != nil {
logger.Logger.WithError(err).Error("Failed to get Bitcoin rate")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to get Bitcoin rate: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, rate)
}
func (albyHttpSvc *AlbyHttpService) albyCurrenciesHandler(c echo.Context) error {
currencies, err := albyHttpSvc.albySvc.GetCurrencies(c.Request().Context())
if err != nil {
logger.Logger.WithError(err).Error("Failed to get currencies")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to get currencies: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, currencies)
}
func (albyHttpSvc *AlbyHttpService) albyStoriesHandler(c echo.Context) error {
stories, err := albyHttpSvc.albyOAuthSvc.GetStories(c.Request().Context())
if err != nil {
logger.Logger.WithError(err).Error("Failed to get stories")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to get stories: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, stories)
}
func (albyHttpSvc *AlbyHttpService) albyCallbackHandler(c echo.Context) error {
code := c.QueryParam("code")
err := albyHttpSvc.albyOAuthSvc.CallbackHandler(c.Request().Context(), code)
if err != nil {
logger.Logger.WithError(err).Error("Failed to handle Alby OAuth callback")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to handle Alby OAuth callback: %s", err.Error()),
})
}
if albyHttpSvc.appConfig.IsDefaultClientId() {
// do not redirect if using default OAuth client
// redirect will be handled by the frontend instead
return c.NoContent(http.StatusNoContent)
}
redirectUrl := albyHttpSvc.appConfig.GetBaseFrontendUrl()
if redirectUrl == "" {
// OAuth using a custom client requires a base URL set for the callback
return errors.New("no BASE_URL set")
}
return c.Redirect(http.StatusFound, redirectUrl)
}
func (albyHttpSvc *AlbyHttpService) albyMeHandler(c echo.Context) error {
me, err := albyHttpSvc.albyOAuthSvc.GetMe(c.Request().Context())
if err != nil {
logger.Logger.WithError(err).Error("Failed to request alby me endpoint")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to request alby me endpoint: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, me)
}
func (albyHttpSvc *AlbyHttpService) albyLinkAccountHandler(c echo.Context) error {
var linkAccountRequest alby.AlbyLinkAccountRequest
if err := c.Bind(&linkAccountRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}
err := albyHttpSvc.albyOAuthSvc.LinkAccount(c.Request().Context(), albyHttpSvc.svc.GetLNClient(), linkAccountRequest.Budget, linkAccountRequest.Renewal)
if err != nil {
logger.Logger.WithError(err).Error("Failed to connect alby account")
return err
}
return c.NoContent(http.StatusNoContent)
}