alby-hub/apps/apps_service.go
Roland 3e1d16d423
Feat: NWA auth for self-hosted hubs (#1016)
* feat: nwc create_connection command (WIP)

* feat: allow creating superuser apps from the ui

* fix: pass methods rather than scopes in create_connection method

* chore: add extra tests

* fix: use browser router in http mode

* fix: update links to not use hash router

* fix: add redirect from hash router url

* fix: return nostrWalletConnectUrl in nwc connection success event and message

* feat: publish nwa event

* chore: use nwc info event instead of nwa event

* feat: create custom alby go detail page

* chore: allow creating/editing apps with the same name

* fix: convert budget from msats to sats (#1111)

* chore: address NWA feedback

* chore: address feedback

- adjust button copy
- make create_connection methods consistent with http deeplink flow
- remove unused constant
- add empty string check before adding lud16 tag
- fix test

* feat: add support for notification_types in create_connection method

* fix: shorter button copy

* fix: do not include lud16 tag in published info event

* Feat: add lud16 to get_info response (#1128)

feat: add lud16 to get_info response

* chore: minor alby go screen improvements

* fix: incorrect unlock password error message to create app with superuser access

* chore: minor ui improvements on alby go detail page

* chore: avoid duplicate app names by adding a suffix

* chore: move scopes check to apps service, add new tests, DRY controller test setup

* fix: scopes component full access scopes and isolated scope group check

* fix: use supported capabilities for Alby Go

* fix: return correct app name

* chore: address minor comments

---------

Co-authored-by: im-adithya <imadithyavardhan@gmail.com>
2025-02-27 19:12:55 +07:00

203 lines
5.3 KiB
Go

package apps
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/service/keys"
"github.com/nbd-wtf/go-nostr"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type AppsService interface {
CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error)
DeleteApp(app *db.App) error
GetAppByPubkey(pubkey string) *db.App
}
type appsService struct {
db *gorm.DB
eventPublisher events.EventPublisher
keys keys.Keys
cfg config.Config
}
func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys, cfg config.Config) *appsService {
return &appsService{
db: db,
eventPublisher: eventPublisher,
keys: keys,
cfg: cfg,
}
}
func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) {
if isolated {
if slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE) {
// cannot sign messages because the isolated app is a custodial sub-wallet
return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope")
}
backendType, _ := svc.cfg.Get("LNBackendType", "")
if backendType != config.LDKBackendType &&
backendType != config.LNDBackendType &&
backendType != config.PhoenixBackendType {
return nil, "", fmt.Errorf(
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
}
}
if budgetRenewal == "" {
budgetRenewal = constants.BUDGET_RENEWAL_NEVER
}
if !slices.Contains(constants.GetBudgetRenewals(), budgetRenewal) {
return nil, "", fmt.Errorf("invalid budget renewal. Must be one of %s", strings.Join(constants.GetBudgetRenewals(), ","))
}
// ensure there is at least one scope
if scopes == nil || len(scopes) == 0 {
return nil, "", errors.New("no scopes provided")
}
var pairingPublicKey string
var pairingSecretKey string
if pubkey == "" {
pairingSecretKey = nostr.GeneratePrivateKey()
pairingPublicKey, _ = nostr.GetPublicKey(pairingSecretKey)
} else {
pairingPublicKey = pubkey
//validate public key
decoded, err := hex.DecodeString(pairingPublicKey)
if err != nil || len(decoded) != 32 {
logger.Logger.WithField("pairingPublicKey", pairingPublicKey).Error("Invalid public key format")
return nil, "", fmt.Errorf("invalid public key format: %s", pairingPublicKey)
}
}
var metadataBytes []byte
if metadata != nil {
var err error
metadataBytes, err = json.Marshal(metadata)
if err != nil {
logger.Logger.WithError(err).Error("Failed to serialize metadata")
return nil, "", err
}
}
// use a suffix to avoid duplicate names
nameIndex := 0
var freeName string
for ; ; nameIndex++ {
freeName = name
if nameIndex > 0 {
freeName += fmt.Sprintf(" (%d)", nameIndex)
}
existingApp := svc.GetAppByName(freeName)
if existingApp == nil {
break
}
}
app := db.App{Name: freeName, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}
err := svc.db.Transaction(func(tx *gorm.DB) error {
err := tx.Save(&app).Error
if err != nil {
return err
}
for _, scope := range scopes {
appPermission := db.AppPermission{
App: app,
Scope: scope,
ExpiresAt: expiresAt,
//these fields are only relevant for pay_invoice
MaxAmountSat: int(maxAmountSat),
BudgetRenewal: budgetRenewal,
}
err = tx.Create(&appPermission).Error
if err != nil {
return err
}
}
appWalletPrivKey, err := svc.keys.GetAppWalletKey(app.ID)
if err != nil {
return fmt.Errorf("error generating wallet child private key: %w", err)
}
appWalletPubkey, err := nostr.GetPublicKey(appWalletPrivKey)
if err != nil {
return fmt.Errorf("error generating wallet child public key: %w", err)
}
err = tx.Model(&app).Update("wallet_pubkey", appWalletPubkey).Error
if err != nil {
return err
}
// commit transaction
return nil
})
if err != nil {
logger.Logger.WithError(err).Error("Failed to save app")
return nil, "", err
}
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_app_created",
Properties: map[string]interface{}{
"name": name,
"id": app.ID,
},
})
return &app, pairingSecretKey, nil
}
func (svc *appsService) DeleteApp(app *db.App) error {
err := svc.db.Delete(app).Error
if err != nil {
return err
}
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_app_deleted",
Properties: map[string]interface{}{
"name": app.Name,
"id": app.ID,
},
})
return nil
}
func (svc *appsService) GetAppByPubkey(pubkey string) *db.App {
dbApp := db.App{}
findResult := svc.db.Where("app_pubkey = ?", pubkey).First(&dbApp)
if findResult.RowsAffected == 0 {
return nil
}
return &dbApp
}
func (svc *appsService) GetAppByName(name string) *db.App {
dbApp := db.App{}
findResult := svc.db.Where("name = ?", name).First(&dbApp)
if findResult.RowsAffected == 0 {
return nil
}
return &dbApp
}