Skip to content
Merged
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
.SILENT:
.PHONY: lint test converage up
.PHONY: lint test race converage up

lint:
go tool -modfile=go.tool.mod golangci-lint run ./...

test:
go test ./... -coverprofile cover.out

race:
go test ./... -race

coverage:
go tool cover -html cover.out

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Running using Docker:
$ docker compose up -d
```

This will start the tgfeed server on port 8080 (can be changed via HTTP_SERVER_PORT environment variable) and a Redis instance for caching.
This will start the tgfeed server on port 8080 (can be changed via HTTP_SERVER_PORT environment variable).

## API Endpoints

Expand Down Expand Up @@ -65,4 +65,4 @@ Replace `channelname` with the username of the Telegram channel you want to foll

## Docker Compose

The service is preconfigured with Redis for caching. You can customize the configuration through environment variables in the `compose.yaml` file.
The service can be run using Docker Compose. Customize the configuration through environment variables in the `compose.yaml` file. You can uncomment some config values there if you want to keep cache in Redis. Otherwise it will be kept in RAM (by default).
27 changes: 14 additions & 13 deletions cmd/tgfeed/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ func main() {

redisHost := os.Getenv("REDIS_HOST")

if redisHost == "" {
redisHost = "redis"
}

// Configure IP filtering
allowedIPsStr := os.Getenv("ALLOWED_IPS")
trustProxy := os.Getenv("REVERSE_PROXY") == "true" || os.Getenv("REVERSE_PROXY") == "1"
Expand All @@ -66,26 +62,31 @@ func main() {
logger.Info("IP filtering enabled", "allowed_ips", allowedIPsStr, "trust_proxy", trustProxy)
}

// Initialize Redis cache
redisClient, err := cache.NewRedisClient(ctx, fmt.Sprintf("%s:6379", redisHost))
var c cache.Cache

if err != nil {
logger.Error("Failed to connect to Redis", "error", err)
os.Exit(1)
if redisHost == "" {
c = cache.NewMemoryClient()
} else {
redisClient, err := cache.NewRedisClient(ctx, fmt.Sprintf("%s:6379", redisHost))

if err != nil {
logger.Error("Failed to connect to Redis", "error", err)
os.Exit(1)
}

c = redisClient
}

defer redisClient.Close()
defer c.Close()

scraper := feed.NewDefaultScraper()
generator := feed.NewGenerator()

// Initialize and run the HTTP server
server := rest.NewServer(redisClient, scraper, generator, ipFilter, port)
server := rest.NewServer(c, scraper, generator, ipFilter, port)

if err := server.Run(ctx); err != nil {
logger.Error("Server error", "error", err)
os.Exit(1)
}

logger.Info("Server exited gracefully")
}
46 changes: 24 additions & 22 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ services:
environment:
- TZ=Europe/Moscow
- HTTP_SERVER_PORT=8080
- REDIS_HOST=redis
# Uncomment the variable if you want to keep cache in Redis
# - REDIS_HOST=redis
# You can specify a custom HTML message for cases when the scraper
# could not obtain the post content from t.me.
# Use {postDeepLink} and {postURL} as placeholders for post links.
Expand All @@ -23,26 +24,27 @@ services:
# - REVERSE_PROXY=true
ports:
- 8080:8080
depends_on:
- redis
# Uncomment depends_on if you want to keep cache in Redis
# depends_on:
# - redis
restart: unless-stopped
# Uncomment everything below if you want to keep cache in Redis
# redis:
# container_name: redis
# image: redis:alpine
# environment:
# - TZ=Europe/Moscow
# ports:
# - 6379:6379
# volumes:
# - redis-data:/data
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 30s
# timeout: 10s
# retries: 5
# start_period: 5s
# restart: unless-stopped

redis:
container_name: redis
image: redis:alpine
environment:
- TZ=Europe/Moscow
ports:
- 6379:6379
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 5s
restart: unless-stopped

volumes:
redis-data:
# volumes:
# redis-data:
4 changes: 4 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package cache

import (
"context"
"errors"
"time"
)

// ErrCacheMiss is returned when a key is not found in the cache
var ErrCacheMiss = errors.New("cache miss")

// Cache defines the interface for caching data
type Cache interface {
// Get retrieves a value from the cache
Expand Down
76 changes: 76 additions & 0 deletions internal/cache/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cache

import (
"context"
"maps"
"sync"
"time"
)

// MemoryCache implements the Cache interface using RAM
type MemoryCache struct {
cache map[string][]byte
mu sync.RWMutex
}

// NewMemoryClient creates a new cache client
func NewMemoryClient() *MemoryCache {
cache := make(map[string][]byte, 100)

return &MemoryCache{cache: cache}
}

// Get retrieves a value from memory
func (c *MemoryCache) Get(_ context.Context, key string) ([]byte, error) {
c.mu.RLock()
val, exists := c.cache[key]
c.mu.RUnlock()

if !exists {
return nil, ErrCacheMiss
}

return val, nil
}

// Set stores a value in memory with the specified TTL
// If ttl is 0, the value will not be cached
func (c *MemoryCache) Set(_ context.Context, key string, value []byte, ttl time.Duration) error {
if ttl == 0 {
return nil // Skip caching if TTL is 0
}

c.mu.Lock()
c.cache[key] = value
c.mu.Unlock()

go func() {
time.Sleep(ttl)
c.mu.Lock()
delete(c.cache, key)
c.mu.Unlock()
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return nil
}

// Close releases the memory
func (c *MemoryCache) Close() error {
c.mu.Lock()
clear(c.cache)
c.mu.Unlock()

return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// snapshot returns a copy of the cache for testing purposes
func (c *MemoryCache) snapshot() map[string][]byte {
c.mu.RLock()
defer c.mu.RUnlock()

result := make(map[string][]byte, len(c.cache))

maps.Copy(result, c.cache)

return result
}
Loading