Compare commits

24 Commits

Author SHA1 Message Date
efe2a42f0e Update az generator 'users' list 2025-05-29 13:54:58 +04:00
56ec1a39a0 Update az generator 'users' list 2025-05-28 19:12:13 +04:00
b9633e84da Update az generator 'users' list 2025-05-28 19:05:23 +04:00
802ff21a42 Update az generator 'IsValid' function conditions 2025-05-28 18:59:53 +04:00
bc29dabd8a Add 'GeneratorHandler' to api 2025-05-28 00:40:06 +04:00
7b89230cae Add example for production deployment 2025-05-28 00:33:01 +04:00
dbf892535b Update az generator 'users' list 2025-05-27 15:13:42 +04:00
1f12a3d090 Update az generator 'users' list 2025-05-27 14:55:01 +04:00
19be0f09d2 Update az generator 'users' list 2025-05-27 14:54:19 +04:00
919fec0aa9 Update az generator 'users' list 2025-05-26 23:38:28 +04:00
3097aba9c3 Update az generator 'users' list 2025-05-26 23:37:58 +04:00
6799ad241e Update az generator 'users' list 2025-05-26 17:14:43 +04:00
3378baf0bc Update az generator 'users' list 2025-05-26 17:14:03 +04:00
94df1dd259 Update az generator 'users' list 2025-05-26 00:50:24 +04:00
ab3baf76b1 Update 'users' list 2025-05-26 00:06:09 +04:00
3c6fb06282 Update 'users' list 2025-05-26 00:05:34 +04:00
ea9d7bb67e Update 'users' list 2025-05-25 19:27:35 +04:00
56581c7332 Update 'users' list 2025-05-25 19:26:56 +04:00
8ea4602141 Update 'users' list 2025-05-25 19:26:23 +04:00
12e51b5a22 Add 'users', 'users/valid/', and 'users/invalid/' endpoints to API 2025-05-24 19:32:36 +04:00
50af73f26a Update 'users' list 2025-05-24 18:37:06 +04:00
35907380fb Merge pull request #8 from aykhans/feat/add-versions
Add version variables
2025-05-24 18:33:32 +04:00
bad7b4a304 Add version variables 2025-05-24 18:30:21 +04:00
9917f61db1 Merge pull request #7 from aykhans/feat/add-auth-middleware
Add auth middleware
2025-05-24 16:17:49 +04:00
34 changed files with 580 additions and 156 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
*.env
prod/**/*.env
.claude

View File

@@ -1,60 +0,0 @@
# Equivalent Makefile for Taskfile.yaml
.PHONY: ftl fmt tidy lint run-consumer run-feedgen-az run-api run-manager generate-env
# Default value for ARGS if not provided on the command line
ARGS ?=
# Runs fmt, tidy, and lint sequentially
ftl:
$(MAKE) fmt
$(MAKE) tidy
$(MAKE) lint
# Format Go code
fmt:
gofmt -w -d .
# Tidy Go modules
tidy:
go mod tidy
# Run golangci-lint
lint:
golangci-lint run
# Run the consumer application, loading environment from dotenv files
run-consumer:
set -a; \
. config/app/.consumer.env; \
. config/app/.mongodb.env; \
set +a; \
go run cmd/consumer/main.go $(ARGS)
# Run the feedgen-az application, loading environment from dotenv files
run-feedgen-az:
set -a; \
. config/app/feedgen/.az.env; \
. config/app/.mongodb.env; \
set +a; \
go run cmd/feedgen/az/main.go $(ARGS)
# Run the api application, loading environment from dotenv files
run-api:
set -a; \
. config/app/.api.env; \
. config/app/.mongodb.env; \
set +a; \
go run cmd/api/main.go
# Run the manager application with arguments (no dotenv)
run-manager:
go run cmd/manager/main.go $(ARGS)
# Generate env files from templates
generate-env:
cp config/app/consumer.env.example config/app/.consumer.env
cp config/app/api.env.example config/app/.api.env
cp config/app/mongodb.env.example config/app/.mongodb.env
cp config/app/feedgen/az.env.example config/app/feedgen/.az.env
cp config/mongodb/env.example config/mongodb/.env

View File

@@ -2,6 +2,9 @@
version: "3"
vars:
DOCKER_REGISTRY: "git.aykhans.me/bsky/"
tasks:
ftl:
cmds:
@@ -16,35 +19,26 @@ tasks:
lint: golangci-lint run
run-consumer:
cmd: go run cmd/consumer/main.go {{.CLI_ARGS}}
cmd: go run ./cmd/consumer {{.CLI_ARGS}}
dotenv:
- config/app/.consumer.env
- config/app/.mongodb.env
- config/app/consumer.env
- config/app/mongodb.env
run-feedgen-az:
cmd: go run cmd/feedgen/az/main.go {{.CLI_ARGS}}
cmd: go run ./cmd/feedgen/az {{.CLI_ARGS}}
dotenv:
- config/app/feedgen/.az.env
- config/app/.mongodb.env
- config/app/feedgen/az.env
- config/app/mongodb.env
run-api:
cmd: go run cmd/api/main.go
cmd: go run ./cmd/api {{.CLI_ARGS}}
dotenv:
- config/app/.api.env
- config/app/.mongodb.env
- config/app/api.env
- config/app/mongodb.env
run-manager:
cmd: go run cmd/manager/main.go {{.CLI_ARGS}}
generate-env:
desc: Generate env files from templates
cmds:
- cp config/app/consumer.env.example config/app/.consumer.env
- cp config/app/api.env.example config/app/.api.env
- cp config/app/mongodb.env.example config/app/.mongodb.env
- cp config/app/feedgen/az.env.example config/app/feedgen/.az.env
- cp config/mongodb/env.example config/mongodb/.env
docker-publish-all:
desc: Publish docker images for all services
cmds:
@@ -55,21 +49,55 @@ tasks:
docker-publish-api:
desc: Publish docker image for api service
vars:
GO_VERSION_FILE: ./cmd/api/version.go
IMAGE_NAME: feedgen-api
VERSION:
sh: grep -o 'const version = "[^"]*"' {{.GO_VERSION_FILE}} | grep -o '"[^"]*"' | tr -d '"'
VERSIONED_IMAGE: "{{.DOCKER_REGISTRY}}{{.IMAGE_NAME}}:{{.VERSION}}"
LATEST_IMAGE: "{{.DOCKER_REGISTRY}}{{.IMAGE_NAME}}:latest"
preconditions:
- test -f {{.GO_VERSION_FILE}}
- sh: '[ -n "{{.VERSION}}" ]'
msg: "Could not extract version from {{.GO_FILE}}"
cmds:
- docker build -t git.aykhans.me/bsky/feedgen-api:latest -f ./cmd/api/Dockerfile .
- docker push git.aykhans.me/bsky/feedgen-api:latest
- docker build -t {{.VERSIONED_IMAGE}} -f ./cmd/api/Dockerfile .
- docker tag {{.VERSIONED_IMAGE}} {{.LATEST_IMAGE}}
- docker push {{.VERSIONED_IMAGE}}
- docker push {{.LATEST_IMAGE}}
- echo "Published {{.VERSIONED_IMAGE}} and {{.LATEST_IMAGE}}"
docker-publish-consumer:
desc: Publish docker image for consumer service
vars:
GO_VERSION_FILE: ./cmd/consumer/version.go
IMAGE_NAME: feedgen-consumer
VERSION:
sh: grep -o 'const version = "[^"]*"' {{.GO_VERSION_FILE}} | grep -o '"[^"]*"' | tr -d '"'
VERSIONED_IMAGE: "{{.DOCKER_REGISTRY}}{{.IMAGE_NAME}}:{{.VERSION}}"
LATEST_IMAGE: "{{.DOCKER_REGISTRY}}{{.IMAGE_NAME}}:latest"
cmds:
- docker build -t git.aykhans.me/bsky/feedgen-consumer:latest -f ./cmd/consumer/Dockerfile .
- docker push git.aykhans.me/bsky/feedgen-consumer:latest
- docker build -t {{.VERSIONED_IMAGE}} -f ./cmd/consumer/Dockerfile .
- docker push {{.VERSIONED_IMAGE}}
- docker tag {{.VERSIONED_IMAGE}} {{.LATEST_IMAGE}}
- docker push {{.LATEST_IMAGE}}
- echo "Published {{.VERSIONED_IMAGE}} and {{.LATEST_IMAGE}}"
docker-publish-feedgen-az:
desc: Publish docker image for feedgen-az service
vars:
GO_VERSION_FILE: ./cmd/feedgen/az/version.go
IMAGE_NAME: feedgen-generator-az
VERSION:
sh: grep -o 'const version = "[^"]*"' {{.GO_VERSION_FILE}} | grep -o '"[^"]*"' | tr -d '"'
VERSIONED_IMAGE: "{{.DOCKER_REGISTRY}}{{.IMAGE_NAME}}:{{.VERSION}}"
LATEST_IMAGE: "{{.DOCKER_REGISTRY}}{{.IMAGE_NAME}}:latest"
cmds:
- docker build -t git.aykhans.me/bsky/feedgen-generator-az:latest -f ./cmd/feedgen/az/Dockerfile .
- docker push git.aykhans.me/bsky/feedgen-generator-az:latest
- docker build -t {{.VERSIONED_IMAGE}} -f ./cmd/feedgen/az/Dockerfile .
- docker push {{.VERSIONED_IMAGE}}
- docker tag {{.VERSIONED_IMAGE}} {{.LATEST_IMAGE}}
- docker push {{.LATEST_IMAGE}}
- echo "Published {{.VERSIONED_IMAGE}} and {{.LATEST_IMAGE}}"
docker-publish-manager:
desc: Publish docker image for manager service

View File

@@ -6,7 +6,7 @@ COPY go.mod go.sum ./
COPY ../../pkg ./pkg
COPY ../../cmd/api ./cmd/api
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o api ./cmd/api/main.go
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o api ./cmd/api
FROM gcr.io/distroless/static-debian12:latest

View File

@@ -2,8 +2,11 @@ package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/aykhans/bsky-feedgen/pkg/api"
@@ -15,11 +18,21 @@ import (
_ "go.uber.org/automaxprocs"
)
type flags struct {
version bool
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go listenForTermination(func() { cancel() })
flags := getFlags()
if flags.version == true {
fmt.Printf("API version: %v\n", version)
os.Exit(0)
}
apiConfig, errMap := config.NewAPIConfig()
if errMap != nil {
logger.Log.Error("API ENV error", "error", errMap.ToStringMap())
@@ -59,3 +72,33 @@ func listenForTermination(do func()) {
<-sigChan
do()
}
func getFlags() *flags {
flags := &flags{}
flag.Usage = func() {
fmt.Println(
`Usage:
consumer [flags]
Flags:
-version version information
-h, -help Display this help message`)
}
flag.BoolVar(&flags.version, "version", false, "print version information")
flag.Parse()
if args := flag.Args(); len(args) > 0 {
if len(args) == 1 {
fmt.Printf("unexpected argument: %s\n\n", args[0])
} else {
fmt.Printf("unexpected arguments: %v\n\n", strings.Join(args, ", "))
}
flag.CommandLine.Usage()
os.Exit(1)
}
return flags
}

3
cmd/api/version.go Normal file
View File

@@ -0,0 +1,3 @@
package main
const version = "0.2.202"

View File

@@ -6,7 +6,7 @@ COPY go.mod go.sum ./
COPY ../../pkg ./pkg
COPY ../../cmd/consumer ./cmd/consumer
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o consumer ./cmd/consumer/main.go
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o consumer ./cmd/consumer
FROM gcr.io/distroless/static-debian12:latest

View File

@@ -20,42 +20,24 @@ import (
_ "go.uber.org/automaxprocs"
)
type flags struct {
version bool
cursorOption types.ConsumerCursor
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go listenForTermination(func() { cancel() })
flag.Usage = func() {
fmt.Println(
`Usage:
consumer [flags]
Flags:
-h, -help Display this help message
-cursor string Specify the starting point for data consumption (default: last-consumed)
Options:
last-consumed: Resume from the last processed data in storage
first-stream: Start from the beginning of the firehose
current-stream: Start from the current position in the firehose stream`)
flags := getFlags()
if flags.version == true {
fmt.Printf("Consumer version: %v\n", version)
os.Exit(0)
}
var cursorOption types.ConsumerCursor
flag.Var(&cursorOption, "cursor", "")
flag.Parse()
if args := flag.Args(); len(args) > 0 {
if len(args) == 1 {
fmt.Printf("unexpected argument: %s\n\n", args[0])
} else {
fmt.Printf("unexpected arguments: %v\n\n", strings.Join(args, ", "))
}
flag.CommandLine.Usage()
os.Exit(1)
}
if cursorOption == "" {
_ = cursorOption.Set("")
if flags.cursorOption == "" {
_ = flags.cursorOption.Set("")
}
consumerConfig, errMap := config.NewConsumerConfig()
@@ -89,7 +71,7 @@ Flags:
ctx,
postCollection,
"wss://bsky.network",
cursorOption,
flags.cursorOption,
consumerConfig.PostMaxDate, // Save only posts created before PostMaxDate
10*time.Second, // Save consumed data to MongoDB every 10 seconds
)
@@ -121,3 +103,39 @@ func listenForTermination(do func()) {
<-sigChan
do()
}
func getFlags() *flags {
flags := &flags{}
flag.Usage = func() {
fmt.Println(
`Usage:
consumer [flags]
Flags:
-version version information
-h, -help Display this help message
-cursor string Specify the starting point for data consumption (default: last-consumed)
Options:
last-consumed: Resume from the last processed data in storage
first-stream: Start from the beginning of the firehose
current-stream: Start from the current position in the firehose stream`)
}
flag.BoolVar(&flags.version, "version", false, "print version information")
flag.Var(&flags.cursorOption, "cursor", "Specify the starting point for data consumption")
flag.Parse()
if args := flag.Args(); len(args) > 0 {
if len(args) == 1 {
fmt.Printf("unexpected argument: %s\n\n", args[0])
} else {
fmt.Printf("unexpected arguments: %v\n\n", strings.Join(args, ", "))
}
flag.CommandLine.Usage()
os.Exit(1)
}
return flags
}

3
cmd/consumer/version.go Normal file
View File

@@ -0,0 +1,3 @@
package main
const version = "0.1.0"

View File

@@ -6,7 +6,7 @@ COPY go.mod go.sum ./
COPY ../../pkg ./pkg
COPY ../../cmd/feedgen/az ./cmd/feedgen/az
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o feedgen ./cmd/feedgen/az/main.go
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o feedgen ./cmd/feedgen/az
FROM gcr.io/distroless/static-debian12:latest

View File

@@ -20,41 +20,24 @@ import (
_ "go.uber.org/automaxprocs"
)
type flags struct {
version bool
cursorOption types.GeneratorCursor
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go listenForTermination(func() { cancel() })
flag.Usage = func() {
fmt.Println(
`Usage:
feedgen-az [flags]
Flags:
-h, -help Display this help message
-cursor string Specify the starting point for feed data generation (default: last-generated)
Options:
last-generated: Resume from the last generated data in storage
first-post: Start from the beginning of the posts`)
flags := getFlags()
if flags.version == true {
fmt.Printf("Feedgen Az version: %v\n", version)
os.Exit(0)
}
var cursorOption types.GeneratorCursor
flag.Var(&cursorOption, "cursor", "")
flag.Parse()
if args := flag.Args(); len(args) > 0 {
if len(args) == 1 {
fmt.Printf("unexpected argument: %s\n\n", args[0])
} else {
fmt.Printf("unexpected arguments: %v\n\n", strings.Join(args, ", "))
}
flag.CommandLine.Usage()
os.Exit(1)
}
if cursorOption == "" {
_ = cursorOption.Set("")
if flags.cursorOption == "" {
_ = flags.cursorOption.Set("")
}
feedGenAzConfig, errMap := config.NewFeedGenAzConfig()
@@ -89,7 +72,7 @@ Flags:
feedGeneratorAz := feedgenAz.NewGenerator(postCollection, feedAzCollection)
startCrons(ctx, feedGenAzConfig, feedGeneratorAz, feedAzCollection, cursorOption)
startCrons(ctx, feedGenAzConfig, feedGeneratorAz, feedAzCollection, flags.cursorOption)
logger.Log.Info("Cron jobs started")
<-ctx.Done()
@@ -139,3 +122,38 @@ func listenForTermination(do func()) {
<-sigChan
do()
}
func getFlags() *flags {
flags := &flags{}
flag.Usage = func() {
fmt.Println(
`Usage:
feedgen-az [flags]
Flags:
-version version information
-h, -help Display this help message
-cursor string Specify the starting point for feed data generation (default: last-generated)
Options:
last-generated: Resume from the last generated data in storage
first-post: Start from the beginning of the posts`)
}
flag.BoolVar(&flags.version, "version", false, "print version information")
flag.Var(&flags.cursorOption, "cursor", "Specify the starting point for feed data generation")
flag.Parse()
if args := flag.Args(); len(args) > 0 {
if len(args) == 1 {
fmt.Printf("unexpected argument: %s\n\n", args[0])
} else {
fmt.Printf("unexpected arguments: %v\n\n", strings.Join(args, ", "))
}
flag.CommandLine.Usage()
os.Exit(1)
}
return flags
}

View File

@@ -0,0 +1,3 @@
package main
const version = "0.1.107"

3
config/app/consumer.env Normal file
View File

@@ -0,0 +1,3 @@
POST_MAX_DATE=720h # Save only posts created in the last month
POST_COLLECTION_CUTOFF_CRON_DELAY=30m # 30 minutes
POST_COLLECTION_CUTOFF_CRON_MAX_DOCUMENT=1000000 # Delete post documents after 1 million

View File

@@ -23,6 +23,7 @@ func Run(
return err
}
feedHandler := handler.NewFeedHandler(feeds, apiConfig.FeedgenPublisherDID)
generatorHandler := handler.NewGeneratorHandler()
authMiddleware := middleware.NewAuth(apiConfig.ServiceDID)
@@ -34,6 +35,9 @@ func Run(
"GET /xrpc/app.bsky.feed.getFeedSkeleton",
authMiddleware.JWTAuthMiddleware(http.HandlerFunc(feedHandler.GetFeedSkeleton)),
)
mux.HandleFunc("GET /{feed}/users", generatorHandler.GetAllUsers)
mux.HandleFunc("GET /{feed}/users/valid/", generatorHandler.GetValidUsers)
mux.HandleFunc("GET /{feed}/users/invalid/", generatorHandler.GetInvalidUsers)
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", apiConfig.APIPort),

View File

@@ -0,0 +1,57 @@
package handler
import (
"net/http"
"github.com/aykhans/bsky-feedgen/pkg/api/response"
generatorAz "github.com/aykhans/bsky-feedgen/pkg/generator/az"
)
type GeneratorHandler struct{}
func NewGeneratorHandler() *GeneratorHandler {
return &GeneratorHandler{}
}
func (handler *GeneratorHandler) GetValidUsers(w http.ResponseWriter, r *http.Request) {
feed := r.PathValue("feed")
validUsers := make([]string, 0)
switch feed {
case "AzPulse":
validUsers = generatorAz.Users.GetValidUsers()
}
response.JSON(w, 200, response.M{
"feed": feed,
"users": validUsers,
})
}
func (handler *GeneratorHandler) GetInvalidUsers(w http.ResponseWriter, r *http.Request) {
feed := r.PathValue("feed")
invalidUsers := make([]string, 0)
switch feed {
case "AzPulse":
invalidUsers = generatorAz.Users.GetInvalidUsers()
}
response.JSON(w, 200, response.M{
"feed": feed,
"users": invalidUsers,
})
}
func (handler *GeneratorHandler) GetAllUsers(w http.ResponseWriter, r *http.Request) {
feed := r.PathValue("feed")
responseData := response.M{"feed": feed}
switch feed {
case "AzPulse":
responseData["valid_users"] = generatorAz.Users.GetValidUsers()
responseData["invalid_users"] = generatorAz.Users.GetInvalidUsers()
}
response.JSON(w, 200, responseData)
}

View File

@@ -5,7 +5,6 @@ package middleware
import (
"crypto"
"errors"
"fmt"
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
"github.com/golang-jwt/jwt/v5"
@@ -44,7 +43,6 @@ func init() {
jwt.RegisterSigningMethod(SigningMethodES256.Alg(), func() jwt.SigningMethod {
return SigningMethodES256
})
fmt.Println("init Completed")
}
// Errors returned on different problems.

View File

@@ -39,7 +39,7 @@ func (f *FeedAz) Describe(_ context.Context) bsky.FeedDescribeFeedGenerator_Feed
func (f *FeedAz) GetPage(
ctx context.Context,
_ string,
_ string, // user did
limit int64,
cursor string,
) ([]*bsky.FeedDefs_SkeletonFeedPost, *string, error) {

View File

@@ -110,16 +110,24 @@ func (generator *Generator) Start(ctx context.Context, cursorOption types.Genera
}
func (generator *Generator) IsValid(post *collections.Post) bool {
// Skip posts that are deep replies (not direct replies to original posts)
if post.Reply != nil && post.Reply.RootURI != post.Reply.ParentURI {
return false
}
if isValidUser := users.IsValid(post.DID); isValidUser != nil {
// Check if the user who created this post is in our pre-defined list
// This allows for explicit inclusion/exclusion of specific users
if isValidUser := Users.IsValid(post.DID); isValidUser != nil {
return *isValidUser
}
if (slices.Contains(post.Langs, "az") && len(post.Langs) < 3) || // Posts in Azerbaijani language with fewer than 3 languages
generator.textRegex.MatchString(post.Text) { // Posts containing Azerbaijan-related keywords
// A post is considered valid if it meets either of the following criteria:
// 1. It's primarily in Azerbaijani (language code "az") with less than 3 detected languages
// (to filter out multi-language spam)
// 2. It contains Azerbaijan-related keywords in the text AND has at least one valid language
// from our approved language list
if (slices.Contains(post.Langs, "az") && len(post.Langs) < 3) ||
(generator.textRegex.MatchString(post.Text) && Langs.IsExistsAny(post.Langs)) {
return true
}

View File

@@ -2,7 +2,7 @@ package az
import "github.com/aykhans/bsky-feedgen/pkg/generator"
var users = generator.Users{
var Users = generator.Users{
// Invalid
"did:plc:5zww7zorx2ajw7hqrhuix3ba": false,
"did:plc:c4vhz47h566t2ntgd7gtawen": false,
@@ -14,6 +14,28 @@ var users = generator.Users{
"did:plc:5vwjnzaibnwscbbcvkzhy57v": false,
"did:plc:6mfp3coadoobuvlg6w2avw6x": false,
"did:plc:lm2uhaoqoe6yo76oeihndfyi": false,
"did:plc:vizwdor43adw3277u2kkrssd": false,
"did:plc:oqatvbgbhvqbjl2w2o63ehgi": false,
"did:plc:gy7yilnydusx5hy2z3dltynp": false,
"did:plc:xk7cs24wk6njv42azm2yd7dv": false,
"did:plc:ijmt7f4p3dcfqtg3j3zshimn": false,
"did:plc:2q5dx6whenn7pnsrfn3jpd6h": false,
"did:plc:s2waw3gkmn7h2nn6od44apng": false,
"did:plc:4hm6gb7dzobynqrpypif3dck": false,
"did:plc:odvarii7w7soygxet3xvzop7": false,
"did:plc:5cbkdchsxjvz5fog2oo7m4le": false,
"did:plc:ooeuisen5rtr4rojmz7gkbrh": false,
"did:plc:6bvhdvgeqkj7nol2zodtqmww": false,
"did:plc:k6sxlkd5ssq2uaylzisap2tw": false,
"did:plc:uxljnh22mmfzmr4i3oien6mx": false,
"did:plc:w5gg2zgwcyfevphehdcmavev": false,
"did:plc:ckawbibgmrwg3lbskfppwtlw": false,
"did:plc:43fdk46qa5gsokzygzildsaq": false,
"did:plc:3szm5t3tknphjtj73twqfonw": false,
"did:plc:4ukvsogndgp67sv6f6ohse3y": false,
"did:plc:cdplzvv63u5jxb4fxm4vpfgm": false,
"did:plc:namifrcorf6hzy45phd4shvt": false,
"did:plc:ltvtwjps77bqgm2knhlbswyk": false,
// Valid
"did:plc:jbt4qi6psd7rutwzedtecsq7": true,
@@ -22,6 +44,16 @@ var users = generator.Users{
"did:plc:phtq2rhgbwipyx5ie3apw44j": true,
"did:plc:jfdvklrs5n5qv7f25v6swc5h": true,
"did:plc:u5ez5w6qslh6advti4wyddba": true,
"did:plc:cs2cbzojm6hmx5lfxiuft3mq": true,
"did:plc:x7alwnnjygt2aqcwblhazko7": true,
"did:plc:mgciyhgfn65z7iazxuar6o6a": true,
"did:plc:ay2f5go4lxq2hspiaqohegac": true,
"did:plc:ftoopigdpuzqt2kpeyqxsofx": true,
"did:plc:cs2cbzojm6hmx5lfxiuft3mq": true,
}
var Langs = generator.Langs{
"az": true,
"en": true,
"tr": true,
"ru": true,
}

View File

@@ -4,6 +4,15 @@ import "github.com/aykhans/bsky-feedgen/pkg/utils"
type Users map[string]bool
// IsValid checks if a given DID exists in the Users map and returns its validity status.
//
// Parameters:
//
// did: The Decentralized Identifier string to check
//
// Returns:
// - *bool: A pointer to the validity status if the DID exists in the map
// - nil: If the DID does not exist in the map
func (u Users) IsValid(did string) *bool {
isValid, ok := u[did]
if ok == false {
@@ -12,3 +21,69 @@ func (u Users) IsValid(did string) *bool {
return utils.ToPtr(isValid)
}
// GetValidUsers returns a slice of DIDs that are marked as valid in the Users map.
//
// Returns:
// - []string: A slice of valid DIDs, limited by the specified parameters
func (u Users) GetValidUsers() []string {
validUsers := make([]string, 0)
for did, isValid := range u {
if isValid {
validUsers = append(validUsers, did)
}
}
return validUsers
}
// GetInvalidUsers returns a slice of DIDs that are marked as invalid in the Users map.
//
// Returns:
// - []string: A slice of invalid DIDs, limited by the specified parameters
func (u Users) GetInvalidUsers() []string {
invalidUsers := make([]string, 0)
for did, isValid := range u {
if !isValid {
invalidUsers = append(invalidUsers, did)
}
}
return invalidUsers
}
// GetAll returns a slice of all DIDs in the Users map, regardless of validity status.
//
// Returns:
// - []string: A slice containing all DIDs in the map
func (u Users) GetAll() []string {
allUsers := make([]string, 0, len(u))
for did := range u {
allUsers = append(allUsers, did)
}
return allUsers
}
type Langs map[string]bool
// IsExistsAny checks if any of the given language codes exist in the Langs map.
//
// Parameters:
// - langs: A slice of language code strings to check for existence
//
// Returns:
// - bool: true if at least one language code from the input slice exists in the map,
// false if none of the provided language codes exist
func (l Langs) IsExistsAny(langs []string) bool {
for _, lang := range langs {
if _, ok := l[lang]; ok {
return true
}
}
return false
}

26
prod/Makefile Normal file
View File

@@ -0,0 +1,26 @@
.PHONY: configure
configure:
@cp config/app/api.env.example config/app/.api.env
@cp config/app/consumer.env.example config/app/.consumer.env
@cp config/app/mongodb.env.example config/app/.mongodb.env
@cp config/app/feedgen/az.env.example config/app/feedgen/.az.env
@cp config/caddy/env.example config/caddy/.env
@cp config/mongodb/env.example config/mongodb/.env
@read -p "Enter MongoDB username: " mongodb_user; \
printf "Enter MongoDB password: "; \
read mongodb_pass; \
sed -i "s/MONGO_INITDB_ROOT_USERNAME=.*/MONGO_INITDB_ROOT_USERNAME=$$mongodb_user/" config/mongodb/.env; \
sed -i "s/MONGO_INITDB_ROOT_PASSWORD=.*/MONGO_INITDB_ROOT_PASSWORD=$$mongodb_pass/" config/mongodb/.env; \
sed -i "s/MONGODB_USERNAME=.*/MONGODB_USERNAME=$$mongodb_user/" config/app/.mongodb.env; \
sed -i "s/MONGODB_PASSWORD=.*/MONGODB_PASSWORD=$$mongodb_pass/" config/app/.mongodb.env
@read -p "Enter domain name (e.g., feeds.bsky.example.com): " domain; \
read -p "Enter your AT Protocol DID: " publisher_did; \
sed -i "s/DOMAIN=.*/DOMAIN=$$domain/" config/caddy/.env; \
sed -i "s|FEEDGEN_HOSTNAME=.*|FEEDGEN_HOSTNAME=https://$$domain|" config/app/.api.env; \
sed -i "s/FEEDGEN_PUBLISHER_DID=.*/FEEDGEN_PUBLISHER_DID=$$publisher_did/" config/app/.api.env
@echo
@echo "Configuration complete! You can now run 'docker compose up -d'"

76
prod/README.md Normal file
View File

@@ -0,0 +1,76 @@
# Example Production Deployment
This is an example of a production deployment for the Feed Generator.
## Architecture
The production setup includes the following services:
- **MongoDB**: Database for storing posts and feed data
- **Consumer**: Service that consumes AT Protocol firehose data
- **Feed Generator (AZ)**: Generates feeds for Azerbaijan-related content
- **API**: REST API service for serving feeds
- **Caddy**: Reverse proxy
## Quick Start
1. **Configure the environment**:
```bash
make configure
```
This will:
- Copy all example configuration files
- Prompt for MongoDB credentials
- Prompt for domain name and AT Protocol DID
- Update configuration files with your values
2. **Start the services**:
```bash
docker compose up -d
```
3. **Check service status**:
```bash
docker compose ps
docker compose logs
```
## Configuration Files
### Application Configuration
- `config/app/.api.env` - API service configuration
- `config/app/.consumer.env` - Consumer service configuration
- `config/app/.mongodb.env` - MongoDB connection settings
- `config/app/feedgen/.az.env` - Azerbaijan feed generator settings
### Infrastructure Configuration
- `config/caddy/.env` - Caddy reverse proxy settings
- `config/caddy/Caddyfile` - Caddy server configuration
- `config/mongodb/.env` - MongoDB initialization settings
## Environment Variables
### API Service
- `FEEDGEN_HOSTNAME` - Public hostname for the feed generator
- `FEEDGEN_PUBLISHER_DID` - Your AT Protocol DID
- `API_PORT` - Port for the API service (default: 8421)
### Consumer Service
- `POST_MAX_DATE` - Maximum age of posts to store (default: 720h/30 days)
- `POST_COLLECTION_CUTOFF_CRON_DELAY` - Cleanup interval (default: 30m)
- `POST_COLLECTION_CUTOFF_CRON_MAX_DOCUMENT` - Max documents before cleanup (default: 1M)
### AZ Feed Generator
- `FEED_AZ_GENERATER_CRON_DELAY` - Feed generation interval (default: 1m)
- `FEED_AZ_COLLECTION_CUTOFF_CRON_DELAY` - Cleanup interval (default: 30m)
- `FEED_AZ_COLLECTION_CUTOFF_CRON_MAX_DOCUMENT` - Max documents before cleanup (default: 500K)
### MongoDB
- `MONGODB_HOST` - MongoDB hostname (default: mongodb)
- `MONGODB_PORT` - MongoDB port (default: 27017)
- `MONGODB_USERNAME` - Database username
- `MONGODB_PASSWORD` - Database password
### Caddy
- `DOMAIN` - Your domain name
- `API_HOST` - Internal API service URL (default: http://api:8421)

View File

@@ -0,0 +1,3 @@
FEEDGEN_HOSTNAME=https://feeds.bsky.example.com
FEEDGEN_PUBLISHER_DID=did:plc:qwertyuiopp
API_PORT=8421

View File

@@ -1,3 +1,3 @@
POST_MAX_DATE=720h # Save only posts created in the last month
POST_COLLECTION_CUTOFF_CRON_DELAY=30m # 30 minutes
POST_COLLECTION_CUTOFF_CRON_MAX_DOCUMENT=10000000 # Delete post documents after 10 million
POST_COLLECTION_CUTOFF_CRON_MAX_DOCUMENT=1000000 # Delete post documents after 1 million

View File

@@ -0,0 +1,3 @@
FEED_AZ_GENERATER_CRON_DELAY=1m # 1 minute
FEED_AZ_COLLECTION_CUTOFF_CRON_DELAY=30m # 30 minutes
FEED_AZ_COLLECTION_CUTOFF_CRON_MAX_DOCUMENT=500000 # Delete post documents after 500 thousand

View File

@@ -0,0 +1,4 @@
MONGODB_HOST=mongodb
MONGODB_PORT=27017
MONGODB_USERNAME=root
MONGODB_PASSWORD=toor

View File

@@ -0,0 +1,11 @@
{
admin off
}
{$DOMAIN} {
request_body {
max_size 8MB
}
reverse_proxy {$API_HOST}
}

View File

@@ -0,0 +1,2 @@
DOMAIN=feeds.bsky.example.com
API_HOST=http://api:8421

View File

@@ -0,0 +1,2 @@
MONGO_INITDB_ROOT_USERNAME=root
MONGO_INITDB_ROOT_PASSWORD=toor

63
prod/docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
services:
mongodb:
image: mongo:8.0.9-noble
restart: unless-stopped
# ports:
# - 27017:27017
env_file: ./config/mongodb/.env
volumes:
- mongodb_data:/data/db
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh --quiet
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
consumer:
image: git.aykhans.me/bsky/feedgen-consumer:latest
restart: unless-stopped
env_file:
- ./config/app/.mongodb.env
- ./config/app/.consumer.env
depends_on:
mongodb:
condition: service_healthy
feedgen_az:
image: git.aykhans.me/bsky/feedgen-generator-az:latest
restart: unless-stopped
env_file:
- ./config/app/.mongodb.env
- ./config/app/feedgen/.az.env
depends_on:
mongodb:
condition: service_healthy
api:
image: git.aykhans.me/bsky/feedgen-api:latest
restart: unless-stopped
ports:
- 8421:8421
env_file:
- ./config/app/.mongodb.env
- ./config/app/.api.env
depends_on:
mongodb:
condition: service_healthy
caddy:
image: caddy:2.10.0-alpine
restart: unless-stopped
ports:
- 80:80
- 443:443
- 443:443/udp
env_file: ./config/caddy/.env
volumes:
- ./config/caddy/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
volumes:
mongodb_data:
caddy_data: