From 5ed72daaf0302c986b82e164c0d1ab6e51c13556 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 25 Nov 2018 23:23:33 -0500 Subject: [PATCH] add dynamodb support (#28) * add dynamodb support * don't export dynamodb table creation function * compatibility fix for older db entries --- aws-sam/README.md | 3 + aws-sam/gggtracker.cfn.yaml | 19 ++++ main.go | 19 +++- server/activity_handler.go | 7 +- server/bolt_database.go | 84 ++++++++++++++++ server/bolt_database_test.go | 22 +++++ server/database.go | 161 ++++++++++--------------------- server/database_test.go | 28 +++--- server/dynamodb_database.go | 141 +++++++++++++++++++++++++++ server/dynamodb_database_test.go | 110 +++++++++++++++++++++ server/forum_indexer.go | 14 ++- server/localization.go | 1 - server/reddit_indexer.go | 14 ++- server/rss_handler.go | 7 +- 14 files changed, 488 insertions(+), 142 deletions(-) create mode 100644 aws-sam/README.md create mode 100644 aws-sam/gggtracker.cfn.yaml create mode 100644 server/bolt_database.go create mode 100644 server/bolt_database_test.go create mode 100644 server/dynamodb_database.go create mode 100644 server/dynamodb_database_test.go diff --git a/aws-sam/README.md b/aws-sam/README.md new file mode 100644 index 0000000..437266d --- /dev/null +++ b/aws-sam/README.md @@ -0,0 +1,3 @@ +# aws-sam + +This directory contains resources for deploying the tracker using the AWS Serverless Application Model (SAM). By deploying via serverless technologies, you can create a cost-effective (free or nearly free) scalable deployment. diff --git a/aws-sam/gggtracker.cfn.yaml b/aws-sam/gggtracker.cfn.yaml new file mode 100644 index 0000000..814247e --- /dev/null +++ b/aws-sam/gggtracker.cfn.yaml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: github.com/ccbrown/gggtracker +Resources: + DynamoDBTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: hk + AttributeType: B + - AttributeName: rk + AttributeType: B + KeySchema: + - AttributeName: hk + KeyType: HASH + - AttributeName: rk + KeyType: RANGE + ProvisionedThroughput: + ReadCapacityUnits: 25 + WriteCapacityUnits: 25 diff --git a/main.go b/main.go index 56cd657..11b2da1 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,10 @@ import ( "fmt" "net/http" "path" + "strings" + "github.com/aws/aws-sdk-go-v2/aws/external" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/labstack/echo" "github.com/labstack/echo/middleware" log "github.com/sirupsen/logrus" @@ -18,17 +21,29 @@ func main() { pflag.IntP("port", "p", 8080, "the port to listen on") pflag.String("staticdir", "./server/static", "the static files to serve") pflag.String("ga", "", "a google analytics account") - pflag.String("db", "./gggtracker.db", "the database path") + pflag.String("db", "./gggtracker.db", "the database file path") + pflag.String("dynamodb-table", "", "if given, DynamoDB will be used instead of a database file") pflag.String("forumsession", "", "the POESESSID cookie for a forum session") viper.BindPFlags(pflag.CommandLine) pflag.Parse() viper.SetEnvPrefix("gggtracker") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() e := echo.New() - db, err := server.OpenDatabase(viper.GetString("db")) + var db server.Database + var err error + if tableName := viper.GetString("dynamodb-table"); tableName != "" { + config, err := external.LoadDefaultAWSConfig() + if err != nil { + log.Fatal(err) + } + db, err = server.NewDynamoDBDatabase(dynamodb.New(config), tableName) + } else { + db, err = server.NewBoltDatabase(viper.GetString("db")) + } if err != nil { log.Fatal(err) } diff --git a/server/activity_handler.go b/server/activity_handler.go index 8bfd3e6..7f883f1 100644 --- a/server/activity_handler.go +++ b/server/activity_handler.go @@ -14,9 +14,12 @@ type jsonResponse struct { Next string `json:"next"` } -func ActivityHandler(db *Database) echo.HandlerFunc { +func ActivityHandler(db Database) echo.HandlerFunc { return func(c echo.Context) error { - activity, next := db.Activity(c.QueryParam("next"), 50, LocaleForRequest(c.Request()).ActivityFilter) + activity, next, err := db.Activity(LocaleForRequest(c.Request()), c.QueryParam("next"), 50) + if err != nil { + return err + } response := jsonResponse{ Next: next, } diff --git a/server/bolt_database.go b/server/bolt_database.go new file mode 100644 index 0000000..5a83aec --- /dev/null +++ b/server/bolt_database.go @@ -0,0 +1,84 @@ +package server + +import ( + "encoding/base64" + + "github.com/boltdb/bolt" +) + +type BoltDatabase struct { + db *bolt.DB +} + +func NewBoltDatabase(path string) (*BoltDatabase, error) { + db, err := bolt.Open(path, 0600, nil) + if err != nil { + return nil, err + } + + db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte("activity")) + if err != nil { + return err + } + return nil + }) + + return &BoltDatabase{ + db: db, + }, nil +} + +func (db *BoltDatabase) AddActivity(activity []Activity) error { + return db.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("activity")) + for _, a := range activity { + k, v, err := marshalActivity(a) + if err != nil { + return err + } + b.Put(k, v) + } + return nil + }) +} + +func (db *BoltDatabase) Activity(locale *Locale, start string, count int) ([]Activity, string, error) { + ret := []Activity(nil) + next := "" + if err := db.db.View(func(tx *bolt.Tx) error { + c := tx.Bucket([]byte("activity")).Cursor() + var k, v []byte + if start == "" { + k, v = c.Last() + } else { + s, err := base64.RawURLEncoding.DecodeString(start) + if err != nil { + k, v = c.Last() + } else { + k, v = c.Seek(s) + if k != nil { + k, v = c.Prev() + } + } + } + for len(ret) < count && k != nil { + activity, err := unmarshalActivity(k, v) + if err != nil { + return err + } else if activity != nil && locale.ActivityFilter(activity) { + ret = append(ret, activity) + next = base64.RawURLEncoding.EncodeToString(k) + } + k, v = c.Prev() + } + return nil + }); err != nil { + return nil, "", err + } + return ret, next, nil +} + +func (db *BoltDatabase) Close() error { + return db.db.Close() +} diff --git a/server/bolt_database_test.go b/server/bolt_database_test.go new file mode 100644 index 0000000..9e1c361 --- /dev/null +++ b/server/bolt_database_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "io/ioutil" + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBoltDatabase(t *testing.T) { + dir, err := ioutil.TempDir("testdata", "db") + require.NoError(t, err) + defer os.RemoveAll(dir) + + db, err := NewBoltDatabase(path.Join(dir, "test.db")) + require.NoError(t, err) + defer db.Close() + + testDatabase(t, db) +} diff --git a/server/database.go b/server/database.go index 7f7cbd7..653103d 100644 --- a/server/database.go +++ b/server/database.go @@ -1,133 +1,70 @@ package server import ( - "encoding/base64" "encoding/binary" - "encoding/json" - "github.com/boltdb/bolt" + json "github.com/json-iterator/go" ) -type Database struct { - db *bolt.DB -} - -func OpenDatabase(path string) (*Database, error) { - db, err := bolt.Open(path, 0600, nil) - if err != nil { - return nil, err - } - - db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte("activity")) - if err != nil { - return err - } - return nil - }) - - return &Database{ - db: db, - }, nil -} - -func (db *Database) Close() { - db.db.Close() -} - const ( ForumPostType = iota RedditCommentType RedditPostType ) -func (db *Database) AddActivity(activity []Activity) { - err := db.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("activity")) - for _, a := range activity { - buf, err := json.Marshal(a) - if err != nil { - return err - } - k := make([]byte, 10) - binary.BigEndian.PutUint64(k, uint64(a.ActivityTime().Unix())<<24) - switch a.(type) { - case *ForumPost: - k[5] = ForumPostType - case *RedditComment: - k[5] = RedditCommentType - case *RedditPost: - k[5] = RedditPostType - } - binary.BigEndian.PutUint32(k[6:], a.ActivityKey()) - b.Put(k, buf) - } - return nil - }) +type Database interface { + AddActivity(activity []Activity) error + Activity(locale *Locale, start string, count int) ([]Activity, string, error) + Close() error +} + +func marshalActivity(a Activity) (key, value []byte, err error) { + buf, err := json.Marshal(a) if err != nil { - panic(err) + return nil, nil, err } + k := make([]byte, 10) + binary.BigEndian.PutUint64(k, uint64(a.ActivityTime().Unix())<<24) + switch a.(type) { + case *ForumPost: + k[5] = ForumPostType + case *RedditComment: + k[5] = RedditCommentType + case *RedditPost: + k[5] = RedditPostType + } + binary.BigEndian.PutUint32(k[6:], a.ActivityKey()) + return k, buf, nil } -func (db *Database) Activity(start string, count int, filter func(Activity) bool) ([]Activity, string) { - ret := []Activity(nil) - next := "" - err := db.db.View(func(tx *bolt.Tx) error { - c := tx.Bucket([]byte("activity")).Cursor() - var k, v []byte - if start == "" { - k, v = c.Last() - } else { - s, err := base64.RawURLEncoding.DecodeString(start) - if err != nil { - k, v = c.Last() - } else { - k, v = c.Seek(s) - if k != nil { - k, v = c.Prev() - } - } +func unmarshalActivity(key, value []byte) (Activity, error) { + switch key[5] { + case ForumPostType: + post := &ForumPost{} + err := json.Unmarshal(value, post) + if err != nil { + return nil, err } - for len(ret) < count && k != nil { - var activity Activity - switch k[5] { - case ForumPostType: - post := &ForumPost{} - err := json.Unmarshal(v, post) - if err != nil { - return err - } - if post.Host == "" { - post.Host = "www.pathofexile.com" - } - if post.Id != 0 { - activity = post - } - case RedditCommentType: - comment := &RedditComment{} - err := json.Unmarshal(v, comment) - if err != nil { - return err - } - activity = comment - case RedditPostType: - post := &RedditPost{} - err := json.Unmarshal(v, post) - if err != nil { - return err - } - activity = post - } - if activity != nil && (filter == nil || filter(activity)) { - ret = append(ret, activity) - next = base64.RawURLEncoding.EncodeToString(k) - } - k, v = c.Prev() + if post.Host == "" { + post.Host = "www.pathofexile.com" } - return nil - }) - if err != nil { - panic(err) + if post.Id != 0 { + return post, nil + } + case RedditCommentType: + comment := &RedditComment{} + err := json.Unmarshal(value, comment) + if err != nil { + return nil, err + } + return comment, nil + case RedditPostType: + post := &RedditPost{} + err := json.Unmarshal(value, post) + if err != nil { + return nil, err + } + return post, nil } - return ret, next + return nil, nil } diff --git a/server/database_test.go b/server/database_test.go index 96153b6..d816b79 100644 --- a/server/database_test.go +++ b/server/database_test.go @@ -1,9 +1,6 @@ package server import ( - "io/ioutil" - "os" - "path" "testing" "time" @@ -11,41 +8,46 @@ import ( "github.com/stretchr/testify/require" ) -func TestDatabase_ForumPosts(t *testing.T) { - dir, err := ioutil.TempDir("testdata", "db") - require.NoError(t, err) - defer os.RemoveAll(dir) +func testDatabase(t *testing.T, db Database) { + t.Run("ForumPosts", func(t *testing.T) { + testDatabase_ForumPosts(t, db) + }) +} - db, err := OpenDatabase(path.Join(dir, "test.db")) - require.NoError(t, err) - defer db.Close() +func testDatabase_ForumPosts(t *testing.T, db Database) { + locale := Locales[0] post1 := &ForumPost{ Id: 9000, Poster: "Chris", Time: time.Unix(1486332365, 0), + Host: locale.ForumHost(), } post2 := &ForumPost{ Id: 9001, Poster: "Chris", Time: time.Unix(1486332364, 0), + Host: locale.ForumHost(), } db.AddActivity([]Activity{post1, post2}) - posts, next := db.Activity("", 1, nil) + posts, next, err := db.Activity(locale, "", 1) + require.NoError(t, err) require.Equal(t, 1, len(posts)) assert.Equal(t, post1.Id, posts[0].(*ForumPost).Id) assert.Equal(t, post1.Poster, posts[0].(*ForumPost).Poster) assert.Equal(t, post1.Time.Unix(), posts[0].(*ForumPost).Time.Unix()) - posts, next = db.Activity(next, 1, nil) + posts, next, err = db.Activity(locale, next, 1) + require.NoError(t, err) require.Equal(t, 1, len(posts)) assert.Equal(t, post2.Id, posts[0].(*ForumPost).Id) assert.Equal(t, post2.Poster, posts[0].(*ForumPost).Poster) assert.Equal(t, post2.Time.Unix(), posts[0].(*ForumPost).Time.Unix()) - posts, _ = db.Activity(next, 1, nil) + posts, _, err = db.Activity(locale, next, 1) + require.NoError(t, err) require.Equal(t, 0, len(posts)) } diff --git a/server/dynamodb_database.go b/server/dynamodb_database.go new file mode 100644 index 0000000..1411b32 --- /dev/null +++ b/server/dynamodb_database.go @@ -0,0 +1,141 @@ +package server + +import ( + "encoding/base64" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" +) + +type DynamoDBDatabase struct { + client *dynamodb.DynamoDB + tableName string +} + +func NewDynamoDBDatabase(client *dynamodb.DynamoDB, tableName string) (*DynamoDBDatabase, error) { + return &DynamoDBDatabase{ + client: client, + tableName: tableName, + }, nil +} + +func dynamoDBActivityHashKey(locale *Locale) []byte { + return []byte("activity_by_locale:" + locale.Subdomain) +} + +func (db *DynamoDBDatabase) AddActivity(activity []Activity) error { + for _, locale := range Locales { + var remaining []Activity + for _, a := range activity { + if locale.ActivityFilter(a) { + remaining = append(remaining, a) + } + } + + for len(remaining) > 0 { + batch := remaining + const maxBatchSize = 25 + if len(batch) > maxBatchSize { + batch = batch[:maxBatchSize] + } + + writeRequests := make([]dynamodb.WriteRequest, len(batch)) + for i, a := range batch { + k, v, err := marshalActivity(a) + if err != nil { + return err + } + writeRequests[i] = dynamodb.WriteRequest{ + PutRequest: &dynamodb.PutRequest{ + Item: map[string]dynamodb.AttributeValue{ + "hk": dynamodb.AttributeValue{ + B: dynamoDBActivityHashKey(locale), + }, + "rk": dynamodb.AttributeValue{ + B: []byte(k), + }, + "v": dynamodb.AttributeValue{ + B: []byte(v), + }, + }, + }, + } + } + unprocessed := map[string][]dynamodb.WriteRequest{ + db.tableName: writeRequests, + } + + for len(unprocessed) > 0 { + result, err := db.client.BatchWriteItemRequest(&dynamodb.BatchWriteItemInput{ + RequestItems: unprocessed, + }).Send() + if err != nil { + return err + } + unprocessed = result.UnprocessedItems + } + + remaining = remaining[len(batch):] + } + } + return nil +} + +func (db *DynamoDBDatabase) Activity(locale *Locale, start string, count int) ([]Activity, string, error) { + var activity []Activity + + var startKey map[string]dynamodb.AttributeValue + if start != "" { + rk, _ := base64.RawURLEncoding.DecodeString(start) + startKey = map[string]dynamodb.AttributeValue{ + "hk": dynamodb.AttributeValue{ + B: dynamoDBActivityHashKey(locale), + }, + "rk": dynamodb.AttributeValue{ + B: rk, + }, + } + } + + condition := "hk = :hash" + attributeValues := map[string]dynamodb.AttributeValue{ + ":hash": dynamodb.AttributeValue{ + B: dynamoDBActivityHashKey(locale), + }, + } + + for len(activity) < count { + result, err := db.client.QueryRequest(&dynamodb.QueryInput{ + TableName: aws.String(db.tableName), + KeyConditionExpression: aws.String(condition), + ExpressionAttributeValues: attributeValues, + ExclusiveStartKey: startKey, + Limit: aws.Int64(int64(count - len(activity))), + ScanIndexForward: aws.Bool(false), + }).Send() + if err != nil { + return nil, "", err + } + for _, item := range result.Items { + if a, err := unmarshalActivity(item["rk"].B, item["v"].B); err != nil { + return nil, "", err + } else if a != nil { + activity = append(activity, a) + } + } + if result.LastEvaluatedKey == nil { + break + } + startKey = result.LastEvaluatedKey + } + + var next string + if startKey != nil { + next = base64.RawURLEncoding.EncodeToString(startKey["rk"].B) + } + return activity, next, nil +} + +func (db *DynamoDBDatabase) Close() error { + return nil +} diff --git a/server/dynamodb_database_test.go b/server/dynamodb_database_test.go new file mode 100644 index 0000000..a8a3f12 --- /dev/null +++ b/server/dynamodb_database_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "crypto/rand" + "encoding/base64" + "os" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/awserr" + "github.com/aws/aws-sdk-go-v2/aws/defaults" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/stretchr/testify/require" +) + +func newDynamoDBTestClient() (*dynamodb.DynamoDB, error) { + endpoint := os.Getenv("DYNAMODB_ENDPOINT") + + config := defaults.Config() + config.Region = "us-east-1" + config.EndpointResolver = aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) { + if endpoint != "" { + return aws.Endpoint{ + URL: endpoint, + }, nil + } + return aws.Endpoint{ + URL: "http://localhost:8000", + }, nil + }) + + credentialsBuf := make([]byte, 20) + if _, err := rand.Read(credentialsBuf); err != nil { + return nil, err + } + credentials := base64.RawURLEncoding.EncodeToString(credentialsBuf) + config.Credentials = aws.NewStaticCredentialsProvider(credentials, credentials, "") + config.Retryer = aws.DefaultRetryer{ + NumMaxRetries: 0, + } + + client := dynamodb.New(config) + if endpoint == "" { + if _, err := client.ListTablesRequest(&dynamodb.ListTablesInput{}).Send(); err != nil { + if err, ok := err.(awserr.Error); ok && err.Code() == "RequestError" { + return nil, nil + } + } + } + return client, nil +} + +func createDynamoDBTable(client *dynamodb.DynamoDB, tableName string) error { + if _, err := client.CreateTableRequest(&dynamodb.CreateTableInput{ + AttributeDefinitions: []dynamodb.AttributeDefinition{ + { + AttributeName: aws.String("hk"), + AttributeType: dynamodb.ScalarAttributeTypeB, + }, { + AttributeName: aws.String("rk"), + AttributeType: dynamodb.ScalarAttributeTypeB, + }, + }, + KeySchema: []dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("hk"), + KeyType: dynamodb.KeyTypeHash, + }, { + AttributeName: aws.String("rk"), + KeyType: dynamodb.KeyTypeRange, + }, + }, + ProvisionedThroughput: &dynamodb.ProvisionedThroughput{ + ReadCapacityUnits: aws.Int64(25), + WriteCapacityUnits: aws.Int64(25), + }, + TableName: &tableName, + }).Send(); err != nil { + return err + } + return client.WaitUntilTableExists(&dynamodb.DescribeTableInput{ + TableName: aws.String(tableName), + }) +} + +func TestDynamoDBDatabase(t *testing.T) { + client, err := newDynamoDBTestClient() + require.NoError(t, err) + if client == nil { + t.Skip("launch a local dynamodb container to run this test: docker run --rm -it -p 8000:8000 dwmkerr/dynamodb -inMemory") + } + + const tableName = "TestDynamoDBDatabase" + + if _, err := client.DeleteTableRequest(&dynamodb.DeleteTableInput{ + TableName: aws.String(tableName), + }).Send(); err == nil { + require.NoError(t, client.WaitUntilTableNotExists(&dynamodb.DescribeTableInput{ + TableName: aws.String(tableName), + })) + } + + require.NoError(t, createDynamoDBTable(client, tableName)) + + db, err := NewDynamoDBDatabase(client, tableName) + require.NoError(t, err) + defer db.Close() + + testDatabase(t, db) +} diff --git a/server/forum_indexer.go b/server/forum_indexer.go index 6ee2ca4..e55cf73 100644 --- a/server/forum_indexer.go +++ b/server/forum_indexer.go @@ -23,7 +23,7 @@ type ForumIndexer struct { } type ForumIndexerConfiguration struct { - Database *Database + Database Database Session string } @@ -85,7 +85,9 @@ func (indexer *ForumIndexer) run() { case <-indexer.closeSignal: return default: - indexer.index(l, account, timezone) + if err := indexer.index(l, account, timezone); err != nil { + log.WithError(err).Error("error indexing forum account: " + account) + } time.Sleep(time.Second) } } @@ -190,7 +192,7 @@ func (indexer *ForumIndexer) forumPosts(locale *Locale, poster string, page int, return posts, nil } -func (indexer *ForumIndexer) index(locale *Locale, poster string, timezone *time.Location) { +func (indexer *ForumIndexer) index(locale *Locale, poster string, timezone *time.Location) error { logger := log.WithFields(log.Fields{ "host": locale.ForumHost(), "poster": poster, @@ -221,9 +223,11 @@ func (indexer *ForumIndexer) index(locale *Locale, poster string, timezone *time time.Sleep(time.Second) } - if len(activity) > 0 { - indexer.configuration.Database.AddActivity(activity) + if len(activity) == 0 { + return nil } + + return indexer.configuration.Database.AddActivity(activity) } func ScrapeForumTimezone(doc *goquery.Document) (*time.Location, error) { diff --git a/server/localization.go b/server/localization.go index f47aad1..83fd1f5 100644 --- a/server/localization.go +++ b/server/localization.go @@ -115,7 +115,6 @@ var ruMonthReplacer = strings.NewReplacer( "дек.", "Dec", ) -// TODO: add translations var Locales = []*Locale{ { IncludeReddit: true, diff --git a/server/reddit_indexer.go b/server/reddit_indexer.go index a65d150..5c7189a 100644 --- a/server/reddit_indexer.go +++ b/server/reddit_indexer.go @@ -12,7 +12,7 @@ import ( ) type RedditIndexerConfiguration struct { - Database *Database + Database Database } type RedditIndexer struct { @@ -50,7 +50,9 @@ func (indexer *RedditIndexer) run() { case <-indexer.closeSignal: return default: - indexer.index(users[next]) + if err := indexer.index(users[next]); err != nil { + log.WithError(err).Error("error indexing reddit user: " + users[next]) + } next += 1 if next >= len(users) { next = 0 @@ -144,7 +146,7 @@ func (indexer *RedditIndexer) redditActivity(user string, page string) ([]Activi return ParseRedditActivity(body) } -func (indexer *RedditIndexer) index(user string) { +func (indexer *RedditIndexer) index(user string) error { logger := log.WithFields(log.Fields{ "user": user, }) @@ -177,7 +179,9 @@ func (indexer *RedditIndexer) index(user string) { time.Sleep(time.Second * 2) } - if len(activity) > 0 { - indexer.configuration.Database.AddActivity(activity) + if len(activity) == 0 { + return nil } + + return indexer.configuration.Database.AddActivity(activity) } diff --git a/server/rss_handler.go b/server/rss_handler.go index 38bc934..8982272 100644 --- a/server/rss_handler.go +++ b/server/rss_handler.go @@ -41,9 +41,12 @@ type rssResponse struct { Channel rssChannel `xml:"channel"` } -func RSSHandler(db *Database) echo.HandlerFunc { +func RSSHandler(db Database) echo.HandlerFunc { return func(c echo.Context) error { - activity, _ := db.Activity(c.QueryParam("next"), 50, LocaleForRequest(c.Request()).ActivityFilter) + activity, _, err := db.Activity(LocaleForRequest(c.Request()), c.QueryParam("next"), 50) + if err != nil { + return err + } response := rssResponse{ Version: "2.0", Atom: "http://www.w3.org/2005/Atom",