apps_service.go raw
1 package apps
2
3 import (
4 "encoding/hex"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/getAlby/go-nostr"
13 "github.com/getAlby/hub/config"
14 "github.com/getAlby/hub/constants"
15 "github.com/getAlby/hub/db"
16 "github.com/getAlby/hub/events"
17 "github.com/getAlby/hub/logger"
18 "github.com/getAlby/hub/service/keys"
19 "gorm.io/datatypes"
20 "gorm.io/gorm"
21 )
22
23 type AppsService interface {
24 CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error)
25 DeleteApp(app *db.App) error
26 GetAppByPubkey(pubkey string) *db.App
27 GetAppById(id uint) *db.App
28 SetAppMetadata(appId uint, metadata map[string]interface{}) error
29 HasLightningAddress(app *db.App) bool
30 }
31
32 type appsService struct {
33 db *gorm.DB
34 eventPublisher events.EventPublisher
35 keys keys.Keys
36 cfg config.Config
37 }
38
39 func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys, cfg config.Config) *appsService {
40 return &appsService{
41 db: db,
42 eventPublisher: eventPublisher,
43 keys: keys,
44 cfg: cfg,
45 }
46 }
47
48 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) {
49 if name == "" {
50 return nil, "", errors.New("no app name provided")
51 }
52 if isolated {
53 if slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE) {
54 // cannot sign messages because the isolated app is a custodial sub-wallet
55 return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope")
56 }
57
58 backendType, _ := svc.cfg.Get("LNBackendType", "")
59 if backendType != config.LDKBackendType &&
60 backendType != config.LNDBackendType &&
61 backendType != config.PhoenixBackendType &&
62 backendType != config.BarkBackendType &&
63 backendType != config.CLNBackendType {
64 return nil, "", fmt.Errorf(
65 "sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN")
66 }
67 }
68
69 if budgetRenewal == "" {
70 budgetRenewal = constants.BUDGET_RENEWAL_NEVER
71 }
72
73 if !slices.Contains(constants.GetBudgetRenewals(), budgetRenewal) {
74 return nil, "", fmt.Errorf("invalid budget renewal. Must be one of %s", strings.Join(constants.GetBudgetRenewals(), ","))
75 }
76
77 // ensure there is at least one scope
78 if len(scopes) == 0 {
79 return nil, "", errors.New("no scopes provided")
80 }
81
82 var pairingPublicKey string
83 var pairingSecretKey string
84 var err error
85 if pubkey == "" {
86 pairingSecretKey = nostr.GeneratePrivateKey()
87 pairingPublicKey, err = nostr.GetPublicKey(pairingSecretKey)
88 if err != nil {
89 return nil, "", err
90 }
91 } else {
92 pairingPublicKey = pubkey
93 //validate public key
94 decoded, err := hex.DecodeString(pairingPublicKey)
95 if err != nil || len(decoded) != 32 {
96 logger.Logger.WithField("pairingPublicKey", pairingPublicKey).Error("Invalid public key format")
97 return nil, "", fmt.Errorf("invalid public key format: %s", pairingPublicKey)
98 }
99 }
100
101 var metadataBytes []byte
102 if metadata != nil {
103 var err error
104 metadataBytes, err = json.Marshal(metadata)
105 if err != nil {
106 logger.Logger.WithError(err).Error("Failed to serialize metadata")
107 return nil, "", err
108 }
109 }
110
111 // use a suffix to avoid duplicate names
112 nameIndex := 0
113 var freeName string
114 for ; ; nameIndex++ {
115 freeName = name
116 if nameIndex > 0 {
117 freeName += fmt.Sprintf(" (%d)", nameIndex)
118 }
119 existingApp := svc.GetAppByName(freeName)
120 if existingApp == nil {
121 break
122 }
123 }
124
125 app := db.App{Name: freeName, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}
126
127 err = svc.db.Transaction(func(tx *gorm.DB) error {
128 err := tx.Save(&app).Error
129 if err != nil {
130 return err
131 }
132
133 for _, scope := range scopes {
134 appPermission := db.AppPermission{
135 App: app,
136 Scope: scope,
137 ExpiresAt: expiresAt,
138 //these fields are only relevant for pay_invoice
139 MaxAmountSat: int(maxAmountSat),
140 BudgetRenewal: budgetRenewal,
141 }
142 err = tx.Create(&appPermission).Error
143 if err != nil {
144 return err
145 }
146 }
147
148 appWalletPrivKey, err := svc.keys.GetAppWalletKey(app.ID)
149 if err != nil {
150 return fmt.Errorf("error generating wallet child private key: %w", err)
151 }
152
153 appWalletPubkey, err := nostr.GetPublicKey(appWalletPrivKey)
154 if err != nil {
155 return fmt.Errorf("error generating wallet child public key: %w", err)
156 }
157
158 err = tx.Model(&app).Update("wallet_pubkey", appWalletPubkey).Error
159 if err != nil {
160 return err
161 }
162
163 // commit transaction
164 return nil
165 })
166
167 if err != nil {
168 logger.Logger.WithError(err).Error("Failed to save app")
169 return nil, "", err
170 }
171
172 svc.eventPublisher.Publish(&events.Event{
173 Event: "nwc_app_created",
174 Properties: map[string]interface{}{
175 "name": name,
176 "id": app.ID,
177 },
178 })
179
180 return &app, pairingSecretKey, nil
181 }
182
183 func (svc *appsService) DeleteApp(app *db.App) error {
184
185 err := svc.db.Delete(app).Error
186 if err != nil {
187 return err
188 }
189 walletPubkey := ""
190 if app.WalletPubkey != nil {
191 // only exists for non-legacy apps
192 walletPubkey = *app.WalletPubkey
193 }
194 svc.eventPublisher.Publish(&events.Event{
195 Event: "nwc_app_deleted",
196 Properties: map[string]interface{}{
197 "name": app.Name,
198 "id": app.ID,
199 "walletPubkey": walletPubkey,
200 },
201 })
202 return nil
203 }
204
205 func (svc *appsService) GetAppByPubkey(pubkey string) *db.App {
206 dbApp := db.App{}
207 findResult := svc.db.Where("app_pubkey = ?", pubkey).First(&dbApp)
208 if findResult.RowsAffected == 0 {
209 return nil
210 }
211 return &dbApp
212 }
213
214 func (svc *appsService) GetAppById(id uint) *db.App {
215 dbApp := db.App{}
216 findResult := svc.db.Where("id = ?", id).First(&dbApp)
217 if findResult.RowsAffected == 0 {
218 return nil
219 }
220 return &dbApp
221 }
222
223 func (svc *appsService) GetAppByName(name string) *db.App {
224 dbApp := db.App{}
225 findResult := svc.db.Where("name = ?", name).First(&dbApp)
226 if findResult.RowsAffected == 0 {
227 return nil
228 }
229 return &dbApp
230 }
231
232 func (svc *appsService) SetAppMetadata(id uint, metadata map[string]interface{}) error {
233 var metadataBytes []byte
234 metadataBytes, err := json.Marshal(metadata)
235 if err != nil {
236 logger.Logger.WithError(err).Error("Failed to serialize metadata")
237 return err
238 }
239
240 err = svc.db.Model(&db.App{}).Where("id", id).Update("metadata", datatypes.JSON(metadataBytes)).Error
241 if err != nil {
242 logger.Logger.WithError(err).WithField("metadata", metadata).Error("failed to update transaction metadata")
243 return err
244 }
245
246 return nil
247 }
248
249 func (svc *appsService) HasLightningAddress(app *db.App) bool {
250 if app.Metadata == nil {
251 return false
252 }
253
254 var metadata map[string]interface{}
255 err := json.Unmarshal(app.Metadata, &metadata)
256 if err != nil {
257 return false
258 }
259
260 lud16, exists := metadata["lud16"]
261 return exists && lud16 != nil
262 }
263