FRE-680: Initial project scaffold with auth & API client
- Set up Go module with Cobra CLI skeleton - Implemented login/logout/session commands with 2FA support - Created ProtonMail API client with rate limiting - Added config management for ~/.config/pop/ - Configured CI/CD pipeline with GitHub Actions - Added Makefile for build/test/lint targets Files: - main.go, go.mod, go.sum - cmd/root.go, cmd/auth.go - internal/auth/session.go - internal/config/config.go - internal/api/client.go - Makefile, README.md, .gitignore - .github/workflows/ci.yml
This commit is contained in:
118
internal/api/client.go
Normal file
118
internal/api/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
)
|
||||
|
||||
type ProtonMailClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
config *config.Config
|
||||
rateLimiter *RateLimiter
|
||||
authHeader string
|
||||
authMu sync.RWMutex
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
requests []time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func NewProtonMailClient(cfg *config.Config) *ProtonMailClient {
|
||||
return &ProtonMailClient{
|
||||
baseURL: cfg.APIBaseURL,
|
||||
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
|
||||
config: cfg,
|
||||
rateLimiter: &RateLimiter{
|
||||
requests: make([]time.Time, 0, cfg.RateLimitReq),
|
||||
limit: cfg.RateLimitReq,
|
||||
window: time.Duration(cfg.RateLimitWin) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) SetAuthHeader(token string) {
|
||||
c.authMu.Lock()
|
||||
defer c.authMu.Unlock()
|
||||
c.authHeader = token
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) getAuthHeader() string {
|
||||
c.authMu.RLock()
|
||||
defer c.authMu.RUnlock()
|
||||
return c.authHeader
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Wait() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-rl.window)
|
||||
|
||||
// Remove old requests outside the window
|
||||
validRequests := make([]time.Time, 0, rl.limit)
|
||||
for _, t := range rl.requests {
|
||||
if t.After(windowStart) {
|
||||
validRequests = append(validRequests, t)
|
||||
}
|
||||
}
|
||||
rl.requests = validRequests
|
||||
|
||||
// Wait if at limit
|
||||
if len(rl.requests) >= rl.limit {
|
||||
sleep := rl.requests[0].Add(rl.window).Sub(now)
|
||||
if sleep > 0 {
|
||||
time.Sleep(sleep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.rateLimiter.Wait()
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record the request
|
||||
c.rateLimiter.mu.Lock()
|
||||
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
|
||||
c.rateLimiter.mu.Unlock()
|
||||
|
||||
// Check for API errors
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var apiErr APIError
|
||||
if err := json.Unmarshal(body, &apiErr); err == nil {
|
||||
resp.Body = io.NopCloser(io.MultiReader(io.NopCloser(bytes.NewReader(body)), bytes.NewReader(body)))
|
||||
return resp, &apiErr
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
HTTPStatus int `json:"-"`
|
||||
Code int `json:"Code,omitempty"`
|
||||
Message string `json:"Message,omitempty"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("API error %d: %s", e.HTTPStatus, e.Message)
|
||||
}
|
||||
90
internal/auth/session.go
Normal file
90
internal/auth/session.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
UID string `json:"uid"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TwoFAEnabled bool `json:"two_factor_enabled"`
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
configDir string
|
||||
sessionFile string
|
||||
}
|
||||
|
||||
func NewSessionManager() *SessionManager {
|
||||
cfg := config.NewConfigManager()
|
||||
return &SessionManager{
|
||||
configDir: cfg.ConfigDir(),
|
||||
sessionFile: filepath.Join(cfg.ConfigDir(), "session.json"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) Login() error {
|
||||
// TODO: Implement interactive login with 2FA support
|
||||
// This will call the ProtonMail API and store the session
|
||||
session := Session{
|
||||
UID: "placeholder-uid",
|
||||
AccessToken: "placeholder-token",
|
||||
RefreshToken: "placeholder-refresh",
|
||||
ExpiresAt: 0,
|
||||
TwoFAEnabled: false,
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.sessionFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write session file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Logged in successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) Logout() error {
|
||||
if err := os.Remove(m.sessionFile); err != nil {
|
||||
return fmt.Errorf("failed to remove session file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Logged out successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetSession() (*Session, error) {
|
||||
data, err := os.ReadFile(m.sessionFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read session file: %w", err)
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse session: %w", err)
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) IsAuthenticated() (bool, error) {
|
||||
_, err := m.GetSession()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
78
internal/config/config.go
Normal file
78
internal/config/config.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
RateLimitReq int `json:"rate_limit_requests"`
|
||||
RateLimitWin int `json:"rate_limit_window_sec"`
|
||||
}
|
||||
|
||||
type ConfigManager struct {
|
||||
configDir string
|
||||
configFile string
|
||||
}
|
||||
|
||||
func NewConfigManager() *ConfigManager {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
homeDir = "~"
|
||||
}
|
||||
|
||||
configDir := filepath.Join(homeDir, ".config", "pop")
|
||||
return &ConfigManager{
|
||||
configDir: configDir,
|
||||
configFile: filepath.Join(configDir, "config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ConfigManager) ConfigDir() string {
|
||||
return m.configDir
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Load() (*Config, error) {
|
||||
defaultConfig := &Config{
|
||||
APIBaseURL: "https://api.protonmail.ch",
|
||||
TimeoutSec: 30,
|
||||
RateLimitReq: 100,
|
||||
RateLimitWin: 60,
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(m.configFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return defaultConfig, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Save(config *Config) error {
|
||||
if err := os.MkdirAll(m.configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.configFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user