diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml new file mode 100644 index 00000000..63f6a5fc --- /dev/null +++ b/.github/workflows/release-dev.yml @@ -0,0 +1,88 @@ +name: Dev Release + +on: + push: + branches: + - dev + +permissions: + contents: write + +jobs: + release-dev: + runs-on: ubuntu-latest + env: + BOT_TOKEN: ${{ secrets.MAIN_BOT_TOKEN }} + CHAT_ID: ${{ vars.DEV_RELEASE_CHAT_IDS }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.21' + + - name: Get short SHA + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: calc_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: true + tag_prefix: "v" + + - name: Tag dev build + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + custom_tag: v${{ steps.calc_version.outputs.major }}.${{ steps.calc_version.outputs.minor }}.${{ steps.calc_version.outputs.patch }}-${{ steps.vars.outputs.sha_short }} + tag_prefix: "" + + - name: Create local tag + run: git tag ${{ steps.tag_version.outputs.new_tag }} + + - name: Run GoReleaser (dev) + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Mark as prerelease + run: gh release edit ${{ steps.tag_version.outputs.new_tag }} --prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Notify Telegram (Dev) + if: success() + run: | + IFS=',' read -ra TARGETS <<< "${{ env.CHAT_ID }}" + for target in "${TARGETS[@]}"; do + target=$(echo "$target" | xargs) + if [[ -z "$target" ]]; then + continue + fi + if [[ "$target" == *":"* ]]; then + chat_id=${target%%:*} + thread_id=${target#*:} + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$chat_id" \ + -d message_thread_id="$thread_id" \ + -d text="๐Ÿงช *New Dev Build Published!*%0A%0A*Tag:* ${{ steps.tag_version.outputs.new_tag }}%0A*Commit:* ${{ github.sha }}%0A*Author:* ${{ github.actor }}%0A%0A[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }})" \ + -d parse_mode="Markdown" + else + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$target" \ + -d text="๐Ÿงช *New Dev Build Published!*%0A%0A*Tag:* ${{ steps.tag_version.outputs.new_tag }}%0A*Commit:* ${{ github.sha }}%0A*Author:* ${{ github.actor }}%0A%0A[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }})" \ + -d parse_mode="Markdown" + fi + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..142071b4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + +jobs: + release: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + env: + BOT_TOKEN: ${{ secrets.MAIN_BOT_TOKEN }} + CHAT_ID: ${{ vars.RELEASE_CHAT_IDS }} + PR_TITLE: ${{ github.event.pull_request.title }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.21' + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + + - name: Create local tag + run: git tag ${{ steps.tag_version.outputs.new_tag }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Notify Telegram Success + if: success() + run: | + IFS=',' read -ra TARGETS <<< "${{ env.CHAT_ID }}" + for target in "${TARGETS[@]}"; do + target=$(echo "$target" | xargs) + if [[ "$target" == *":"* ]]; then + chat_id=${target%%:*} + thread_id=${target#*:} + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$chat_id" \ + -d message_thread_id="$thread_id" \ + -d text="๐Ÿš€ *New Release Published!*%0A%0A*Version:* ${{ steps.tag_version.outputs.new_tag }}%0A*Title:* ${PR_TITLE}%0A*Author:* ${{ github.actor }}%0A%0A[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }})" \ + -d parse_mode="Markdown" + else + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$target" \ + -d text="๐Ÿš€ *New Release Published!*%0A%0A*Version:* ${{ steps.tag_version.outputs.new_tag }}%0A*Title:* ${PR_TITLE}%0A*Author:* ${{ github.actor }}%0A%0A[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }})" \ + -d parse_mode="Markdown" + fi + done + diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..07222a8b --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,24 @@ +project_name: BitcoinDeepaBot +builds: + - env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + flags: + - -a + - -installsuffix + - cgo + ldflags: + - -s -w +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/internal/api/lightning.go b/internal/api/lightning.go index 105c3d1a..bbdcb1d7 100644 --- a/internal/api/lightning.go +++ b/internal/api/lightning.go @@ -9,12 +9,14 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/LightningTipBot/LightningTipBot/internal/utils" "github.com/gorilla/mux" "github.com/r3labs/sse" ) type Service struct { - Bot *telegram.TipBot + Bot *telegram.TipBot + MemoCache *utils.Cache } type ErrorResponse struct { diff --git a/internal/api/send.go b/internal/api/send.go index 10b5b76f..0c3c353f 100644 --- a/internal/api/send.go +++ b/internal/api/send.go @@ -118,7 +118,7 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { RespondError(w, "Authentication failed") return } - + walletID := authenticatedWallet.(string) wallet, exists := GetWhitelistedWallets()[walletID] if !exists { @@ -126,7 +126,7 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { RespondError(w, "Invalid wallet configuration") return } - + fromUsername := wallet.Username // Validate request @@ -150,6 +150,38 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { return } + if req.Memo != "" { + memoLockKey := fmt.Sprintf("api_send_memo_%s", req.Memo) + + // Try to acquire lock first to prevent concurrent processing + if success := s.MemoCache.SetNX(memoLockKey, "locked"); !success { + log.Warnf("[api/send] Transaction with memo '%s' is already processing", req.Memo) + RespondError(w, fmt.Sprintf("Transaction with memo '%s' is already processing", req.Memo)) + return + } + // Unlock when done + defer s.MemoCache.Delete(memoLockKey) + + // Check if transaction with this memo already exists in database + // We search for the memo in the transaction memo field + // The stored memo format is: "๐Ÿ’ธ API Send from @User to @User. Memo: " + // So we search for the suffix "Memo: " + var count int64 + memoSearch := fmt.Sprintf("%%Memo: %s", req.Memo) + err := s.Bot.DB.Transactions.Model(&telegram.Transaction{}).Where("memo LIKE ? AND success = ?", memoSearch, true).Count(&count).Error + if err != nil { + log.Errorf("[api/send] Database error checking for duplicate memo: %v", err) + // Continue but log error - fail open or closed? Let's fail closed for safety + RespondError(w, "Internal server error checking transaction history") + return + } + if count > 0 { + log.Warnf("[api/send] Transaction with memo '%s' already completed", req.Memo) + RespondError(w, fmt.Sprintf("Transaction with memo '%s' already completed", req.Memo)) + return + } + } + // Clean usernames (remove @ if present) toIdentifier := strings.TrimPrefix(req.To, "@") diff --git a/internal/utils/cache.go b/internal/utils/cache.go index 8fe13674..f90db015 100644 --- a/internal/utils/cache.go +++ b/internal/utils/cache.go @@ -43,3 +43,26 @@ func (c *Cache) Get(key string) (string, bool) { } return item.value, true } + +func (c *Cache) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + delete(c.data, key) +} + +// SetNX sets the key if it does not exist or has expired. Returns true if set, false if already exists. +func (c *Cache) SetNX(key string, value string) bool { + c.mutex.Lock() + defer c.mutex.Unlock() + + item, exists := c.data[key] + if exists && time.Now().Before(item.expiration) { + return false + } + + c.data[key] = CacheItem{ + value: value, + expiration: time.Now().Add(c.ttl), + } + return true +} diff --git a/main.go b/main.go index b78b798a..158613cd 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "net/http" "runtime/debug" "strings" + "time" "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/api" @@ -21,6 +22,7 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/lnbits/webhook" "github.com/LightningTipBot/LightningTipBot/internal/price" + "github.com/LightningTipBot/LightningTipBot/internal/utils" log "github.com/sirupsen/logrus" ) @@ -89,7 +91,10 @@ func startApiServer(bot *telegram.TipBot) { s.AppendAuthorizedRoute(`/lndhub/ext`, api.AuthTypeBearerBase64, api.AccessKeyTypeAdmin, bot.DB.Users, hub.Handle) // starting api service - apiService := api.Service{Bot: bot} + apiService := api.Service{ + Bot: bot, + MemoCache: utils.NewCache(time.Minute * 5), + } s.AppendAuthorizedRoute(`/api/v1/paymentstatus/{payment_hash}`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.PaymentStatus, http.MethodPost) s.AppendAuthorizedRoute(`/api/v1/invoicestatus/{payment_hash}`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.InvoiceStatus, http.MethodPost) s.AppendAuthorizedRoute(`/api/v1/payinvoice`, api.AuthTypeBasic, api.AccessKeyTypeAdmin, bot.DB.Users, apiService.PayInvoice, http.MethodPost) @@ -101,7 +106,7 @@ func startApiServer(bot *telegram.TipBot) { if internal.IsAPISendEnabled() { s.AppendRoute(`/api/v1/send`, api.WalletHMACMiddleware(apiService.Send), http.MethodPost) log.Infof("API Send endpoint registered at /api/v1/send with wallet-based HMAC security") - + // User balance endpoint with wallet-based HMAC security s.AppendRoute(`/api/v1/userbalance`, api.WalletHMACMiddleware(apiService.UserBalance), http.MethodPost) log.Infof("API UserBalance endpoint registered at /api/v1/userbalance with wallet-based HMAC security")