Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
.DS_*
cfg/config.yml
config.yml
bin/
bin/
tblogs
xauth
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ APP = tblogs

build:
go build -o bin/$(APP) ./cmd/$(APP)
go build -o bin/xauth ./cmd/xauth

run: build
bin/$(APP)
Expand All @@ -15,14 +16,14 @@ lint:
golangci-lint run

clean:
rm -rf bin/$(APP)
rm -rf bin/$(APP) bin/xauth

install:
go install ./cmd/$(APP)

help:
@echo "Common commands:"
@echo " make build # Build the app"
@echo " make build # Build the app and xauth helper"
@echo " make run # Build and run the app"
@echo " make test # Run tests"
@echo " make lint # Run linter"
Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A fast, modern, and hackable terminal blog reader written in Go. No external API

- Browse and follow a curated list of tech/dev blogs
- Read posts from any RSS or Atom feed
- View your X (Twitter) timeline
- Save your favorite posts
- Search and filter blogs
- All data stored locally in a config file (no external API)
Expand Down Expand Up @@ -50,12 +51,14 @@ tblogs
- Use keyboard shortcuts to navigate:
- `Ctrl+B` — Blogs
- `Ctrl+T` — Home
- `Ctrl+X` — X Timeline
- `Ctrl+P` — Saved Posts
- `Ctrl+H` — Help
- `Ctrl+F` — Search
- `Ctrl+S` — Save/follow
- `Ctrl+D` — Delete saved post
- `Ctrl+L` — Toggle last login mode. When on, only posts published after the last login date are shown.
- `Ctrl+R` — Reload X timeline
- Select a blog to view its posts (fetched live from the feed)
- Press `Enter` on a post to open it in your browser

Expand All @@ -70,6 +73,63 @@ tblogs
- You can edit the config file directly to add/remove blogs, or use the app UI.
- Default blogs are defined in [`internal/config/default_blogs.yml`](internal/config/default_blogs.yml).

### X (Twitter) Integration

To enable X timeline viewing:

1. **Set up OAuth 2.0 App:**

- Visit the [X Developer Portal](https://developer.twitter.com/)
- Create a new app or use an existing one
- Enable OAuth 2.0 with the following scopes:
- `tweet.read` - Read tweets
- `users.read` - Read user information
- Set the redirect URI to `http://127.0.0.1:8080/callback`
- **Important**: Make sure your app has OAuth 2.0 enabled (not just OAuth 1.0a)

2. **Get OAuth 2.0 Credentials:**

- Note your **Client ID** and **Client Secret**
- Use the included helper script: `./bin/xauth <client_id> <client_secret>`
- Or use tools like [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/)

3. **Configure your credentials:**
Edit your config file (`~/.config/tblogs/data.yml`) and add:

```yaml
app:
x_cred:
client_id: "your_client_id_here"
client_secret: "your_client_secret_here"
access_token: "your_access_token_here"
refresh_token: "" # Can be empty, will be filled if provided
username: "your_x_username"
```

4. **Use the X timeline:**
- Press `Ctrl+X` to navigate to the X timeline
- Press `Ctrl+R` to reload the timeline
- Click on any post to open it in your browser

**Note:** The app will use your access token. If it expires, you'll need to re-run the OAuth flow to get a new one.

### Troubleshooting

**"Request was not matched" error:**

- Ensure your X app has OAuth 2.0 enabled (not just OAuth 1.0a)
- Make sure the redirect URI exactly matches: `http://localhost:8080/callback`
- Verify your app has the correct scopes: `tweet.read` and `users.read`

**"Invalid client" error:**

- Double-check your Client ID and Client Secret
- Ensure your app is approved and active in the X Developer Portal

**"Invalid redirect URI" error:**

- The redirect URI in your X app settings must exactly match: `http://127.0.0.1:8080/callback`

---

## Releases & Distribution
Expand Down
201 changes: 201 additions & 0 deletions cmd/xauth/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package main

import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)

const (
authURL = "https://twitter.com/i/oauth2/authorize"
tokenURL = "https://api.twitter.com/2/oauth2/token"
redirectURI = "http://127.0.0.1:8080/callback" // Changed from localhost to 127.0.0.1
)

type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}

// generateCodeVerifier generates a random code verifier for PKCE
func generateCodeVerifier() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}

// generateCodeChallenge generates a code challenge from the verifier
func generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}

func main() {
if len(os.Args) != 3 {
fmt.Println("Usage: xauth <client_id> <client_secret>")
fmt.Println("This will help you get OAuth 2.0 tokens for X API")
fmt.Println()
fmt.Println("IMPORTANT: Make sure your X app has:")
fmt.Println("- OAuth 2.0 enabled")
fmt.Println("- Callback URI set to: http://127.0.0.1:8080/callback")
fmt.Println("- App permissions set to 'Read'")
fmt.Println("- Type of App set to 'Web App, Automated App or Bot'")
os.Exit(1)
}

clientID := os.Args[1]
clientSecret := os.Args[2]

fmt.Println("X OAuth 2.0 Token Helper")
fmt.Println("=========================")
fmt.Println()
fmt.Printf("Client ID: %s\n", clientID)
fmt.Printf("Redirect URI: %s\n", redirectURI)
fmt.Println()

// Generate PKCE parameters
codeVerifier, err := generateCodeVerifier()
if err != nil {
log.Fatal("Failed to generate code verifier:", err)
}
codeChallenge := generateCodeChallenge(codeVerifier)

// Step 1: Generate authorization URL with proper PKCE
authParams := url.Values{}
authParams.Set("response_type", "code")
authParams.Set("client_id", clientID)
authParams.Set("redirect_uri", redirectURI)
authParams.Set("scope", "tweet.read users.read")
authParams.Set("state", "state")
authParams.Set("code_challenge", codeChallenge)
authParams.Set("code_challenge_method", "S256")

authURLWithParams := authURL + "?" + authParams.Encode()

fmt.Println("Step 1: Open this URL in your browser to authorize the app:")
fmt.Println(authURLWithParams)
fmt.Println()

// Step 2: Start local server to receive the callback
fmt.Println("Step 2: Starting local server to receive authorization code...")
fmt.Printf("Server will listen on: %s\n", redirectURI)
fmt.Println("After authorizing, you'll be redirected to this URL")
fmt.Println()

var authCode string
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Received callback request: %s %s\n", r.Method, r.URL.String())

code := r.URL.Query().Get("code")
if code != "" {
authCode = code
_, err := fmt.Fprintf(w, "Authorization successful! You can close this window.")
if err != nil {
log.Fatal(err)
}

fmt.Println("Authorization code received successfully!")
go func() {
// Give the browser a moment to show the success message
time.Sleep(2 * time.Second)
os.Exit(0)
}()
} else {
error := r.URL.Query().Get("error")
errorDescription := r.URL.Query().Get("error_description")
_, err := fmt.Fprintf(w, "Authorization failed! Error: %s - %s", error, errorDescription)
if err != nil {
log.Fatal(err)
}

fmt.Printf("Authorization failed! Error: %s - %s\n", error, errorDescription)
}
})

go func() {
fmt.Printf("Starting server on %s...\n", redirectURI)
if err := http.ListenAndServe("127.0.0.1:8080", nil); err != nil {
log.Fatal("Server error:", err)
}
}()

// Wait for the authorization code
fmt.Println("Waiting for authorization...")
for authCode == "" {
time.Sleep(100 * time.Millisecond)
}

fmt.Println("Step 3: Exchanging authorization code for tokens...")

// Step 3: Exchange authorization code for tokens with PKCE
tokenParams := url.Values{}
tokenParams.Set("grant_type", "authorization_code")
tokenParams.Set("code", authCode)
tokenParams.Set("redirect_uri", redirectURI)
tokenParams.Set("client_id", clientID)
tokenParams.Set("code_verifier", codeVerifier)

req, err := http.NewRequest("POST", tokenURL, strings.NewReader(tokenParams.Encode()))
if err != nil {
log.Fatal(err)
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Basic "+basicAuth(clientID, clientSecret))

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}

defer func() {
if err := resp.Body.Close(); err != nil {
log.Fatal(err)
}
}()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Token exchange failed with status %d: %s\n", resp.StatusCode, string(body))
os.Exit(1)
}

var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
log.Fatal(err)
}

fmt.Println("Step 4: Tokens received successfully!")
fmt.Println()
fmt.Println("Add these to your config file (~/.config/tblogs/data.yml):")
fmt.Println()
fmt.Printf("app:\n")
fmt.Printf(" x_cred:\n")
fmt.Printf(" client_id: \"%s\"\n", clientID)
fmt.Printf(" client_secret: \"%s\"\n", clientSecret)
fmt.Printf(" access_token: \"%s\"\n", tokenResp.AccessToken)
fmt.Printf(" refresh_token: \"%s\"\n", tokenResp.RefreshToken)
fmt.Printf(" username: \"your_x_username\"\n")
fmt.Println()
fmt.Println("Note: Replace 'your_x_username' with your actual X username")
}

func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ func NewApp(cfg *config.Config) *App {
app.goToSection(homeSection, info)
case tcell.KeyCtrlP:
app.goToSection(savedPostsSection, info)
case tcell.KeyCtrlX:
app.goToSection(xSection, info)
}
return event
})
Expand Down
2 changes: 2 additions & 0 deletions internal/app/pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ const (
helpSection = "Help"
blogsSection = "Blogs"
savedPostsSection = "Saved Posts"
xSection = "X"
)

func (a *App) getPagesInfo() (*tview.Pages, *tview.TextView) {
slides := []func(func()) (string, tview.Primitive){
a.homePage,
a.savedPostsPage,
a.blogsPage,
a.xPage,
a.helpPage,
}

Expand Down
Loading