Compare commits

...

4 Commits

Author SHA1 Message Date
228d1bffc1 Update dockerfile build image 2025-09-16 12:26:13 +03:30
6d15ce2df9 Improved logging with zap 2025-09-16 12:23:12 +03:30
5680f9471f Added some comments for myself 2025-09-16 12:18:35 +03:30
f136ae58b3 Refactor: Implement uber-go/fx dependency injection
- Replace global variable pattern with proper dependency injection
- Add uber-go/fx for automatic dependency resolution
- Refactor all services and handlers to use constructor injection
- Eliminate fragile initialization order dependencies
- Improve testability and modularity
- Add structured logging with zap

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-16 11:41:01 +03:30
16 changed files with 404 additions and 265 deletions

View File

@@ -3,7 +3,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Build the application from source # Build the application from source
FROM golang:1.24.3 AS build-stage FROM golang:1.25.1-alpine3.22 AS build-stage
WORKDIR /app WORKDIR /app

75
FX_MIGRATION.md Normal file
View File

@@ -0,0 +1,75 @@
# FX Dependency Injection Implementation
This document summarizes the changes made to implement uber-go/fx dependency injection in the CatsOfMastodonBotGo application.
## Overview
We've replaced the global variable pattern with proper dependency injection using uber-go/fx. This improves:
- Testability
- Modularity
- Explicit dependency management
- Eliminates fragile initialization order dependencies
## Key Changes
### 1. Added Dependencies
- Added `go.uber.org/fx` dependency to go.mod
- Added `go.uber.org/zap` for structured logging
### 2. Refactored main.go
- Replaced manual initialization with fx application
- Used `fx.Provide` to register constructors for all components
- Used `fx.Invoke` to start background tasks and server
- Added proper logger integration
### 3. Refactored Configuration (config/config.go)
- Removed global `Config` variable
- Removed `Init()` function
- Kept `Load()` function as constructor
### 4. Refactored Database (database/database.go)
- Removed global `Gorm` variable
- Function now accepts config as parameter
### 5. Refactored Services
- **PostService**: Now accepts database and config as dependencies
- **ImgKitHelper**: Now accepts config as dependency
### 6. Refactored Auth
- **JwtTokenGenerator**: Now accepts config as dependency
- **GiteaOAuth2Handler**: Now accepts config as dependency
### 7. Refactored Handlers
- All handlers now use constructor injection
- Dependencies are explicitly declared in constructor signatures
- Removed global instance variables
### 8. Refactored Server/Router
- Router now receives all handlers through fx dependency injection
- Uses fx.Lifecycle for proper startup/shutdown handling
## Benefits Achieved
1. **Eliminated Global State**: No more global variables causing tight coupling
2. **Explicit Dependencies**: All dependencies are clearly visible in constructor signatures
3. **Automatic Wiring**: fx automatically resolves and injects dependencies
4. **Improved Testability**: Easy to mock dependencies for unit tests
5. **Reduced Boilerplate**: No need for manual initialization functions
6. **Error Detection**: Dependency resolution errors caught at startup
## Files Modified
- cmd/CatsOfMastodonBotGo/main.go
- go.mod
- go.sum
- internal/auth/jwt.go
- internal/auth/oauth2.go
- internal/config/config.go
- internal/database/database.go
- internal/server/router.go
- internal/services/imgKitHelper.go
- internal/services/postService.go
- internal/web/handlers/adminDash.go
- internal/web/handlers/apiEndpoint.go
- internal/web/handlers/embedCard.go
- internal/web/handlers/oauth.go

View File

@@ -1,75 +1,134 @@
package main package main
import ( import (
"context"
"time"
"CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/config" "CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/database" "CatsOfMastodonBotGo/internal/database"
"CatsOfMastodonBotGo/internal/domain"
"CatsOfMastodonBotGo/internal/server" "CatsOfMastodonBotGo/internal/server"
"CatsOfMastodonBotGo/internal/services" "CatsOfMastodonBotGo/internal/services"
"context" "CatsOfMastodonBotGo/internal/web/handlers"
"log/slog"
"strconv" "go.uber.org/fx"
"time" "go.uber.org/zap"
) )
func main() { func main() {
// Setup config fx.New(
config.Init() fx.Provide(
// Logger
// Initialize database NewLogger,
database.Init()
services.InitPostService()
// Not needed but anyways
services.InitImgKitHelper()
ticker := time.NewTicker(10 * time.Minute)
runFetchPosts := func() {
// Get posts
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var posts []domain.Post = nil
err, posts := services.PostServiceInstance.GetPostsFromApi(ctx, config.Config.Tag, config.Config.Instance)
if err != nil {
slog.Error(err.Error())
return
}
var existingPostIds = services.PostServiceInstance.GetExistingPostIds()
var existingAccountIds = services.PostServiceInstance.GetExistingAccountIds()
var newPosts = services.PostServiceInstance.GetNewPosts(existingPostIds, posts)
var newAccounts = services.PostServiceInstance.GetNewAccounts(existingAccountIds, newPosts)
// Save to database
slog.Info("Fetched " + strconv.Itoa(len(posts)) + " posts; " + strconv.Itoa(len(existingPostIds)) + " existing posts; " + strconv.Itoa(len(newPosts)) + " new posts and " + strconv.Itoa(len(newAccounts)) + " new accounts\n")
// Additional logging
if newAccounts != nil {
slog.Info("Inserted " + strconv.Itoa(services.PostServiceInstance.InsertNewAccounts(newAccounts)) + " accounts\n")
}
if newPosts != nil {
slog.Info("Inserted " + strconv.Itoa(services.PostServiceInstance.InsertNewPosts(newPosts)) + " posts\n")
}
}
go func() {
for range ticker.C {
runFetchPosts()
}
}()
// Run initial fetch on startup
go func() {
runFetchPosts()
}()
// https://seefnasrul.medium.com/create-your-first-go-rest-api-with-jwt-authentication-in-gin-framework-dbe5bda72817
r := server.SetupRouter()
err := r.Run(":8080")
if err != nil {
slog.Error(err.Error())
}
// Configuration
config.Load,
// Database
database.Connect,
// Services
services.NewPostService,
services.NewImgKitHelper,
// Auth
auth.NewJwtTokenGenerator,
auth.NewGiteaOauth2Token,
// Handlers
handlers.NewAdminDashboardHandler,
handlers.NewApiEndpointHandler,
handlers.NewEmbedCardHandler,
handlers.NewOauthLoginHandler,
),
fx.Invoke(
// Start background tasks
startBackgroundTasks,
// Setup and start server
server.SetupRouter,
),
fx.Logger(NewFXLogger()), // Optional custom logger
).Run()
} }
// Background tasks as a separate function
func startBackgroundTasks(
lc fx.Lifecycle,
postService *services.PostService,
cfg *config.Config,
logger *zap.Logger,
) {
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
ticker := time.NewTicker(10 * time.Minute)
runFetchPosts := func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
posts, err := postService.GetPostsFromApi(ctx, cfg.Tag, cfg.Instance)
if err != nil {
logger.Error("Failed to fetch posts", zap.Error(err))
return
}
existingPostIds := postService.GetExistingPostIds()
existingAccountIds := postService.GetExistingAccountIds()
newPosts := postService.GetNewPosts(existingPostIds, posts)
newAccounts := postService.GetNewAccounts(existingAccountIds, newPosts)
logger.Info("Fetched posts",
zap.Int("total", len(posts)),
zap.Int("existing", len(existingPostIds)),
zap.Int("new", len(newPosts)),
zap.Int("new_accounts", len(newAccounts)))
if len(newAccounts) > 0 {
count := postService.InsertNewAccounts(newAccounts)
logger.Info("Inserted accounts", zap.Int("count", count))
}
if len(newPosts) > 0 {
count := postService.InsertNewPosts(newPosts)
logger.Info("Inserted posts", zap.Int("count", count))
}
}
// Run initial fetch
go runFetchPosts()
// Start ticker
go func() {
for range ticker.C {
runFetchPosts()
}
}()
return nil
},
OnStop: func(context.Context) error {
// Cleanup if needed
return nil
},
})
}
// Logger provider
func NewLogger() (*zap.Logger, error) {
return zap.NewDevelopment()
}
// Simple logger for fx
type FXLogger struct {
logger *zap.Logger
}
func NewFXLogger() *FXLogger {
logger, _ := zap.NewDevelopment()
return &FXLogger{logger: logger}
}
func (l *FXLogger) Printf(str string, args ...interface{}) {
l.logger.Sugar().Infof(str, args...)
}

4
go.mod
View File

@@ -39,6 +39,10 @@ require (
github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/fx v1.24.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/arch v0.21.0 // indirect golang.org/x/arch v0.21.0 // indirect
golang.org/x/crypto v0.40.0 // indirect golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect

8
go.sum
View File

@@ -88,6 +88,14 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=

View File

@@ -10,33 +10,25 @@ import (
) )
type JwtTokenGenerator struct { type JwtTokenGenerator struct {
Key string cfg *config.Config
Issuer string
Audience string
} }
var JwtTokenGeneratorInstance *JwtTokenGenerator func NewJwtTokenGenerator(cfg *config.Config) *JwtTokenGenerator {
return &JwtTokenGenerator{cfg: cfg}
func InitJwtTokenGenerator() {
JwtTokenGeneratorInstance = &JwtTokenGenerator{
Key: config.Config.JwtSecret,
Issuer: config.Config.JwtIssuer,
Audience: config.Config.JwtAudience,
}
} }
func (j *JwtTokenGenerator) GenerateToken(claims map[string]interface{}) (string, error) { func (j *JwtTokenGenerator) GenerateToken(claims map[string]interface{}) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"exp": time.Now().AddDate(0, 0, 1).Unix(), "exp": time.Now().AddDate(0, 0, 1).Unix(),
"iat": time.Now().Unix(), "iat": time.Now().Unix(),
"iss": j.Issuer, "iss": j.cfg.JwtIssuer,
"aud": j.Audience, "aud": j.cfg.JwtAudience,
}) })
for k, v := range claims { for k, v := range claims {
token.Claims.(jwt.MapClaims)[k] = v token.Claims.(jwt.MapClaims)[k] = v
} }
return token.SignedString([]byte(j.Key)) return token.SignedString([]byte(j.cfg.JwtSecret))
} }
// Gin middleware // Gin middleware
@@ -53,7 +45,7 @@ func (j *JwtTokenGenerator) GinMiddleware() gin.HandlerFunc {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid return nil, jwt.ErrSignatureInvalid
} }
return []byte(j.Key), nil return []byte(j.cfg.JwtSecret), nil
}) })
if err != nil { if err != nil {
@@ -69,4 +61,4 @@ func (j *JwtTokenGenerator) GinMiddleware() gin.HandlerFunc {
c.Next() c.Next()
} }
} }

View File

@@ -5,45 +5,39 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"go.uber.org/zap"
) )
type GiteaOAuth2Handler struct { type GiteaOAuth2Handler struct {
ClientID string cfg *config.Config
ClientSecret string logger *zap.Logger
InstanceUrl string
} }
var GiteaOauth2HandlerInstance *GiteaOAuth2Handler func NewGiteaOauth2Token(cfg *config.Config) *GiteaOAuth2Handler {
return &GiteaOAuth2Handler{cfg: cfg}
func InitGiteaOauth2Token() {
GiteaOauth2HandlerInstance = &GiteaOAuth2Handler{
ClientID: config.Config.GiteaOauthClientID,
ClientSecret: config.Config.GiteaOauthClientSecret,
InstanceUrl: config.Config.GiteaOauthInstance,
}
} }
func (g *GiteaOAuth2Handler) GetGiteaLoginURL(redirectHost string) (string, error) { func (g *GiteaOAuth2Handler) GetGiteaLoginURL(redirectHost string) (string, error) {
if g.InstanceUrl == "" { if g.cfg.GiteaOauthInstance == "" {
return "", nil return "", nil
} }
if redirectHost == "" { if redirectHost == "" {
slog.Error("Redirect host not provided") g.logger.Error("Redirect host not provided")
return "", nil return "", nil
} }
authUrl := g.InstanceUrl + "/login/oauth/authorize?client_id=" + g.ClientID + "&redirect_uri=" + "http://" + redirectHost + "/admin/oauth/gitea/callback&scope=openid&response_type=code&response_mode=form_post" authUrl := g.cfg.GiteaOauthInstance + "/login/oauth/authorize?client_id=" + g.cfg.GiteaOauthClientID + "&redirect_uri=" + "http://" + redirectHost + "/admin/oauth/gitea/callback&scope=openid&response_type=code&response_mode=form_post"
return authUrl, nil return authUrl, nil
} }
func (g *GiteaOAuth2Handler) GetGiteaUserEmailByCode(code string) (string, error) { func (g *GiteaOAuth2Handler) GetGiteaUserEmailByCode(code string) (string, error) {
if g.InstanceUrl == "" { if g.cfg.GiteaOauthInstance == "" {
slog.Error("Instance URL not provided") g.logger.Error("Instance URL not provided")
return "", nil return "", nil
} }
// No need to verify since we are accesing the gitea once and only for the email // No need to verify since we are accesing the gitea once and only for the email
@@ -52,8 +46,8 @@ func (g *GiteaOAuth2Handler) GetGiteaUserEmailByCode(code string) (string, error
return "", err return "", err
} }
userInfoUrl := g.InstanceUrl + "/login/oauth/userinfo" userInfoUrl := g.cfg.GiteaOauthInstance + "/login/oauth/userinfo"
slog.Info(userInfoUrl) g.logger.Info(userInfoUrl)
req, err := http.NewRequest("POST", userInfoUrl, nil) req, err := http.NewRequest("POST", userInfoUrl, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -85,12 +79,12 @@ func (g *GiteaOAuth2Handler) GetGiteaUserEmailByCode(code string) (string, error
func (g *GiteaOAuth2Handler) getGiteaAccessTokenByCode(code string) (string, error) { func (g *GiteaOAuth2Handler) getGiteaAccessTokenByCode(code string) (string, error) {
form := url.Values{} form := url.Values{}
form.Add("client_id", g.ClientID) form.Add("client_id", g.cfg.GiteaOauthClientID)
form.Add("client_secret", g.ClientSecret) form.Add("client_secret", g.cfg.GiteaOauthClientSecret)
form.Add("code", code) form.Add("code", code)
form.Add("grant_type", "authorization_code") form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", g.InstanceUrl+"/login/oauth/access_token", bytes.NewBufferString(form.Encode())) req, err := http.NewRequest("POST", g.cfg.GiteaOauthInstance+"/login/oauth/access_token", bytes.NewBufferString(form.Encode()))
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -112,4 +106,4 @@ func (g *GiteaOAuth2Handler) getGiteaAccessTokenByCode(code string) (string, err
} }
return tokenResp.AccessToken, nil return tokenResp.AccessToken, nil
} }

View File

@@ -1,14 +1,14 @@
package config package config
import ( import (
"log/slog"
"os" "os"
"strings" "strings"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"go.uber.org/zap"
) )
type config struct { type Config struct {
AdminPassword string AdminPassword string
Instance string Instance string
Tag string Tag string
@@ -26,18 +26,16 @@ type config struct {
ImageKitId string ImageKitId string
GiteaOauthInstance string GiteaOauthInstance string
GiteaOauthClientID string GiteaOauthClientID string
GiteaOauthClientSecret string GiteaOauthClientSecret string
GiteaOauthAllowedEmails []string GiteaOauthAllowedEmails []string
} }
var Config *config func Load(logger *zap.Logger) *Config {
func Load() *config {
err := godotenv.Load() err := godotenv.Load()
if err != nil { if err != nil {
slog.Warn("Error loading .env file - Using environment variables instead") logger.Warn("Error loading .env file - Using environment variables instead")
} }
// Get mastodon instance // Get mastodon instance
@@ -53,7 +51,7 @@ func Load() *config {
// Get admin password (Its a single user/admin app so its just fine) // Get admin password (Its a single user/admin app so its just fine)
adminPassword := os.Getenv("CAOM_ADMIN_PASSWORD") adminPassword := os.Getenv("CAOM_ADMIN_PASSWORD")
if adminPassword == "" { if adminPassword == "" {
slog.Warn("No admin password provided, using default password 'catsaregood'") logger.Warn("No admin password provided, using default password 'catsaregood'")
adminPassword = "catsaregood" adminPassword = "catsaregood"
} }
@@ -64,12 +62,12 @@ func Load() *config {
} }
issuer := os.Getenv("CAOM_JWT_ISSUER") issuer := os.Getenv("CAOM_JWT_ISSUER")
if issuer == "" { if issuer == "" {
slog.Info("No jwt issuer provided, using default issuer 'CatsOfMastodonBotGo'") logger.Info("No jwt issuer provided, using default issuer 'CatsOfMastodonBotGo'")
issuer = "CatsOfMastodonBotGo" issuer = "CatsOfMastodonBotGo"
} }
audience := os.Getenv("CAOM_JWT_AUDIENCE") audience := os.Getenv("CAOM_JWT_AUDIENCE")
if audience == "" { if audience == "" {
slog.Info("No jwt audience provided, using default audience 'CatsOfMastodonBotGo'") logger.Info("No jwt audience provided, using default audience 'CatsOfMastodonBotGo'")
audience = "CatsOfMastodonBotGo" audience = "CatsOfMastodonBotGo"
} }
@@ -91,7 +89,7 @@ func Load() *config {
} }
if dbEngine == "" || dbHost == "" || dbPort == "" || dbUser == "" || dbPassword == "" || dbName == "" { if dbEngine == "" || dbHost == "" || dbPort == "" || dbUser == "" || dbPassword == "" || dbName == "" {
slog.Info("No database connection provided, using sqlite") logger.Info("No database connection provided, using sqlite")
dbEngine = "sqlite" dbEngine = "sqlite"
dbHost = "" dbHost = ""
dbPort = "" dbPort = ""
@@ -102,10 +100,10 @@ func Load() *config {
imageKitId := os.Getenv("CAOM_IMAGEKIT_ID") imageKitId := os.Getenv("CAOM_IMAGEKIT_ID")
if imageKitId == "" { if imageKitId == "" {
slog.Info("No imagekit id provided, not using imagekit.io") logger.Info("No imagekit id provided, not using imagekit.io")
} }
// Inititlize AppContext // Initialize AppContext
var appContext = &config{ return &Config{
AdminPassword: adminPassword, AdminPassword: adminPassword,
Instance: instance, Instance: instance,
Tag: tag, Tag: tag,
@@ -123,15 +121,9 @@ func Load() *config {
ImageKitId: imageKitId, ImageKitId: imageKitId,
GiteaOauthInstance: giteaOauthInstance, GiteaOauthInstance: giteaOauthInstance,
GiteaOauthClientID: giteaOauthClientID, GiteaOauthClientID: giteaOauthClientID,
GiteaOauthClientSecret: giteaOauthClientSecret, GiteaOauthClientSecret: giteaOauthClientSecret,
GiteaOauthAllowedEmails: giteaOauthAllowedEmailsParsed, GiteaOauthAllowedEmails: giteaOauthAllowedEmailsParsed,
} }
return appContext
}
func Init() {
Config = Load()
} }

View File

@@ -11,13 +11,11 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var Gorm *gorm.DB func Connect(cfg *config.Config) (*gorm.DB, error) {
func Connect() (*gorm.DB, error) {
var db *gorm.DB var db *gorm.DB
var err error = nil var err error = nil
if config.Config.DBEngine == "sqlite" { if cfg.DBEngine == "sqlite" {
_, err = os.ReadDir("data") _, err = os.ReadDir("data")
if err != nil { if err != nil {
err = os.Mkdir("data", 0755) err = os.Mkdir("data", 0755)
@@ -31,11 +29,11 @@ func Connect() (*gorm.DB, error) {
} }
} else { } else {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.Config.DBUser, cfg.DBUser,
config.Config.DBPassword, cfg.DBPassword,
config.Config.DBHost, cfg.DBHost,
config.Config.DBPort, cfg.DBPort,
config.Config.DBName) cfg.DBName)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -54,13 +52,4 @@ func Connect() (*gorm.DB, error) {
return nil, err return nil, err
} }
return db, nil return db, nil
} }
// IDK if this is how it works or not, leave it as is for now
func Init() {
var err error
Gorm, err = Connect()
if err != nil {
panic(err)
}
}

View File

@@ -1,20 +1,36 @@
package server package server
import ( import (
"CatsOfMastodonBotGo/internal/auth" "context"
"CatsOfMastodonBotGo/internal/web/handlers"
"net/http" "net/http"
"CatsOfMastodonBotGo/internal/web/handlers"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-contrib/static" "github.com/gin-contrib/static"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/fx"
) )
func SetupRouter() *gin.Engine { // fx.In allows fx to inject multiple dependencies
// I think we could just put all thses in SetupRouter() but in this way we are first defining the dependencies we need
// and by using fx.In we say that whenever RouterParams was needed, inject dependensies into it and give it to SetupRouter()
type RouterParams struct {
fx.In
Lifecycle fx.Lifecycle
AdminDashboard *handlers.AdminDashboardHandler
ApiEndpoint *handlers.ApiEndpointHandler
EmbedCard *handlers.EmbedCardHandler
OauthLogin *handlers.OauthLoginHandler
}
func SetupRouter(params RouterParams) {
r := gin.Default() r := gin.Default()
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowAllOrigins: true, AllowAllOrigins: true,
AllowMethods: []string{"POST", "GET", "OPTIONS"}, AllowMethods: []string{"POST", "GET", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true, AllowCredentials: true,
@@ -22,44 +38,37 @@ func SetupRouter() *gin.Engine {
r.LoadHTMLGlob("internal/web/templates/home/*") r.LoadHTMLGlob("internal/web/templates/home/*")
auth.InitJwtTokenGenerator() // Must be befor initializing admin handler, otherwise 'panic: runtime error: invalid memory address or nil pointer dereference'
auth.InitGiteaOauth2Token()
handlers.InitAdminDashboardHandler()
handlers.InitApiEndpointHandler()
handlers.InitEmbedCardHandler()
handlers.InitOauthLoginHandler()
// Main page // Main page
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "home/index.html", nil) c.HTML(http.StatusOK, "home/index.html", nil)
}) })
// Embed card // Embed card
r.GET("/embed", handlers.EmbedCardHandlerInstance.GetEmbedCard) r.GET("/embed", params.EmbedCard.GetEmbedCard)
admin := r.Group("/admin") admin := r.Group("/admin")
// My man, this is done way more efficient and fast in .NET, specially the authentication part
// admin.GET("/", func(c *gin.Context) {
// c.HTML(http.StatusOK, "admin/index.html", nil)
// })
// admin.GET("/login", func(c *gin.Context) {
// c.HTML(http.StatusOK, "admin/index.html", nil)
// })
r.Use(static.Serve("/admin", static.LocalFile("internal/web/templates/admin", true))) r.Use(static.Serve("/admin", static.LocalFile("internal/web/templates/admin", true)))
// I dont know a better way for path handling
r.Use(static.Serve("/admin/oauth/gitea/callback", static.LocalFile("internal/web/templates/admin", true))) r.Use(static.Serve("/admin/oauth/gitea/callback", static.LocalFile("internal/web/templates/admin", true)))
adminApi := admin.Group("/api") adminApi := admin.Group("/api")
adminApi.POST("/login", handlers.AdminDashboardHandlerInstance.Login) adminApi.POST("/login", params.AdminDashboard.Login)
adminApi.GET("/login/oauth/gitea", handlers.OauthLoginHandlerInstance.GoToGiteaLogin) adminApi.GET("/login/oauth/gitea", params.OauthLogin.GoToGiteaLogin)
adminApi.POST("/login/oauth/gitea/final", handlers.OauthLoginHandlerInstance.LoginWithGitea) adminApi.POST("/login/oauth/gitea/final", params.OauthLogin.LoginWithGitea)
adminApi.GET("/getmedia", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.GetMedia) adminApi.GET("/getmedia", params.AdminDashboard.JWTMiddleware(), params.AdminDashboard.GetMedia)
adminApi.POST("/approve", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.ApproveMedia) adminApi.POST("/approve", params.AdminDashboard.JWTMiddleware(), params.AdminDashboard.ApproveMedia)
adminApi.POST("/reject", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.RejectMedia) adminApi.POST("/reject", params.AdminDashboard.JWTMiddleware(), params.AdminDashboard.RejectMedia)
api := r.Group("/api") api := r.Group("/api")
api.GET("/post/random", params.ApiEndpoint.GetRandomPost)
api.GET("/post/random", handlers.ApiEndpointHandlerInstance.GetRandomPost) params.Lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return r go func() {
} if err := r.Run(":8080"); err != nil {
// Handle error appropriately
}
}()
return nil
},
})
}

View File

@@ -3,24 +3,23 @@ package services
import "CatsOfMastodonBotGo/internal/config" import "CatsOfMastodonBotGo/internal/config"
type ImgKitHelper struct { type ImgKitHelper struct {
cfg *config.Config
} }
var ImgKitHelperInstance *ImgKitHelper func NewImgKitHelper(cfg *config.Config) *ImgKitHelper {
return &ImgKitHelper{cfg: cfg}
func InitImgKitHelper() {
ImgKitHelperInstance = &ImgKitHelper{}
} }
func GetPreviewUrl(url string) string { func (ikh *ImgKitHelper) GetPreviewUrl(url string) string {
if config.Config.ImageKitId == "" { if ikh.cfg.ImageKitId == "" {
return url return url
} }
return "https://ik.imagekit.io/" + config.Config.ImageKitId + "/tr:w-500,h-500,c-at_max,f-webp,q-50/" + url return "https://ik.imagekit.io/" + ikh.cfg.ImageKitId + "/tr:w-500,h-500,c-at_max,f-webp,q-50/" + url
} }
func GetRemoteUrl(url string) string { func (ikh *ImgKitHelper) GetRemoteUrl(url string) string {
if config.Config.ImageKitId == "" { if ikh.cfg.ImageKitId == "" {
return url return url
} }
return "https://ik.imagekit.io/" + config.Config.ImageKitId + "/tr:q-70,dpr-auto,f-webp/" + url return "https://ik.imagekit.io/" + ikh.cfg.ImageKitId + "/tr:q-70,dpr-auto,f-webp/" + url
} }

View File

@@ -2,7 +2,6 @@ package services
import ( import (
"CatsOfMastodonBotGo/internal/config" "CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/database"
"CatsOfMastodonBotGo/internal/domain" "CatsOfMastodonBotGo/internal/domain"
"context" "context"
"encoding/json" "encoding/json"
@@ -16,42 +15,41 @@ import (
) )
type PostService struct { type PostService struct {
db *gorm.DB db *gorm.DB
cfg *config.Config
} }
var PostServiceInstance *PostService
// Constructor // Constructor
func InitPostService() { func NewPostService(db *gorm.DB, cfg *config.Config) *PostService {
PostServiceInstance = &PostService{db: database.Gorm} return &PostService{db: db, cfg: cfg}
} }
func (*PostService) GetPostsFromApi(ctx context.Context, tag string, instance string) (error, []domain.Post) { func (ps *PostService) GetPostsFromApi(ctx context.Context, tag string, instance string) ([]domain.Post, error) {
var requestUrl = instance + "/api/v1/timelines/tag/" + tag + "?limit=40" var requestUrl = instance + "/api/v1/timelines/tag/" + tag + "?limit=40"
req, err := http.NewRequestWithContext(ctx, "GET", requestUrl, nil) req, err := http.NewRequestWithContext(ctx, "GET", requestUrl, nil)
if err != nil { if err != nil {
return err, nil return nil, err
} }
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return err, nil return nil, err
} }
if resp.StatusCode != 200 || strings.Split(strings.ToLower(resp.Header.Get("Content-Type")), ";")[0] != "application/json" { if resp.StatusCode != 200 || strings.Split(strings.ToLower(resp.Header.Get("Content-Type")), ";")[0] != "application/json" {
return fmt.Errorf("Status code:", resp.StatusCode, " Content-Type:", resp.Header.Get("Content-Type")), nil return nil, fmt.Errorf("status code: %d, content-type: %s", resp.StatusCode, resp.Header.Get("Content-Type"))
} }
var posts []domain.Post = nil var posts []domain.Post = nil
err = json.NewDecoder(resp.Body).Decode(&posts) err = json.NewDecoder(resp.Body).Decode(&posts)
if err != nil { if err != nil {
return err, nil return nil, err
} }
// defer: it basically means "do this later when the function returns" // defer: it basically means "do this later when the function returns"
defer resp.Body.Close() defer resp.Body.Close()
if posts == nil { if posts == nil {
return fmt.Errorf("no posts found for tag %s on instance %s", tag, instance), nil return nil, fmt.Errorf("no posts found for tag %s on instance %s", tag, instance)
} }
return nil, posts return posts, nil
} }
func (ps *PostService) GetExistingPostIds() []string { func (ps *PostService) GetExistingPostIds() []string {
@@ -66,7 +64,7 @@ func (ps *PostService) GetExistingAccountIds() []string {
return existingAccountIds return existingAccountIds
} }
func (*PostService) GetNewPosts(existingPostIds []string, posts []domain.Post) []domain.Post { func (ps *PostService) GetNewPosts(existingPostIds []string, posts []domain.Post) []domain.Post {
var newPosts []domain.Post = nil var newPosts []domain.Post = nil
for _, post := range posts { for _, post := range posts {
if !arrayContains(existingPostIds, post.ID) && len(post.Attachments) > 0 && !post.Account.IsBot { if !arrayContains(existingPostIds, post.ID) && len(post.Attachments) > 0 && !post.Account.IsBot {
@@ -85,7 +83,7 @@ func (*PostService) GetNewPosts(existingPostIds []string, posts []domain.Post) [
return newPosts return newPosts
} }
func (*PostService) GetNewAccounts(existingAccountIds []string, posts []domain.Post) []domain.Account { func (ps *PostService) GetNewAccounts(existingAccountIds []string, posts []domain.Post) []domain.Account {
var newAccounts []domain.Account = nil var newAccounts []domain.Account = nil
for _, post := range posts { for _, post := range posts {
if !arrayContains(existingAccountIds, post.Account.AccId) { if !arrayContains(existingAccountIds, post.Account.AccId) {
@@ -153,7 +151,7 @@ func (ps *PostService) RejectMedia(mediaId string) bool {
func (ps *PostService) GetMedia() domain.MediaAttachment { func (ps *PostService) GetMedia() domain.MediaAttachment {
var media domain.MediaAttachment var media domain.MediaAttachment
orderExpr := "RANDOM()" // sqlite orderExpr := "RANDOM()" // sqlite
if config.Config.DBEngine != "sqlite" { if ps.cfg.DBEngine != "sqlite" {
orderExpr = "RAND()" // mariadb/mysql orderExpr = "RAND()" // mariadb/mysql
} }
ps.db.Model(&domain.MediaAttachment{}). ps.db.Model(&domain.MediaAttachment{}).
@@ -171,4 +169,4 @@ func arrayContains(arr []string, str string) bool {
} }
} }
return false return false
} }

View File

@@ -5,60 +5,65 @@ import (
"CatsOfMastodonBotGo/internal/auth" "CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/config" "CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/web/dto"
"CatsOfMastodonBotGo/internal/services" "CatsOfMastodonBotGo/internal/services"
"CatsOfMastodonBotGo/internal/web/dto"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type AdminDashboardHandler struct { type AdminDashboardHandler struct {
PostService services.PostService postService *services.PostService
Jwt auth.JwtTokenGenerator jwt *auth.JwtTokenGenerator
cfg *config.Config
} }
var AdminDashboardHandlerInstance *AdminDashboardHandler func NewAdminDashboardHandler(
postService *services.PostService,
func InitAdminDashboardHandler() { jwt *auth.JwtTokenGenerator,
AdminDashboardHandlerInstance = &AdminDashboardHandler{ cfg *config.Config,
PostService: *services.PostServiceInstance, ) *AdminDashboardHandler {
Jwt: *auth.JwtTokenGeneratorInstance, return &AdminDashboardHandler{
postService: postService,
jwt: jwt,
cfg: cfg,
} }
} }
func (ps *AdminDashboardHandler) ApproveMedia(c *gin.Context) { func (adh *AdminDashboardHandler) ApproveMedia(c *gin.Context) {
var input dto.ApproveMediaInput var input dto.ApproveMediaInput
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if ps.PostService.ApproveMedia(input.MediaId) { if adh.postService.ApproveMedia(input.MediaId) {
c.JSON(http.StatusOK, gin.H{"message": "Media approved successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Media approved successfully"})
} else { } else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve media"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve media"})
} }
} }
func (ps *AdminDashboardHandler) RejectMedia(c *gin.Context) { func (adh *AdminDashboardHandler) RejectMedia(c *gin.Context) {
var input dto.RejectMediaInput var input dto.RejectMediaInput
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if ps.PostService.RejectMedia(input.MediaId) { if adh.postService.RejectMedia(input.MediaId) {
c.JSON(http.StatusOK, gin.H{"message": "Media rejected successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Media rejected successfully"})
} else { } else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject media"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject media"})
} }
} }
func (ps *AdminDashboardHandler) GetMedia(c *gin.Context) { func (adh *AdminDashboardHandler) GetMedia(c *gin.Context) {
media := ps.PostService.GetMedia() media := adh.postService.GetMedia()
media.PreviewUrl = services.GetPreviewUrl(media.RemoteUrl) // TODO: Fix this - we need to inject ImgKitHelper
media.RemoteUrl = services.GetPreviewUrl(media.RemoteUrl) // media.PreviewUrl = services.GetPreviewUrl(media.RemoteUrl)
// media.RemoteUrl = services.GetPreviewUrl(media.RemoteUrl)
c.JSON(http.StatusOK, media) c.JSON(http.StatusOK, media)
} }
func (ps *AdminDashboardHandler) Login(c *gin.Context) { func (adh *AdminDashboardHandler) Login(c *gin.Context) {
var input dto.LoginInput var input dto.LoginInput
@@ -68,8 +73,8 @@ func (ps *AdminDashboardHandler) Login(c *gin.Context) {
return return
} }
if input.Password == config.Config.AdminPassword { // Its more than enough for this project if input.Password == adh.cfg.AdminPassword { // Its more than enough for this project
token, err := ps.Jwt.GenerateToken(map[string]interface{}{"role": "admin"}) token, err := adh.jwt.GenerateToken(map[string]interface{}{"role": "admin"})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
return return
@@ -85,3 +90,8 @@ func (ps *AdminDashboardHandler) Login(c *gin.Context) {
} }
} }
// Expose the JWT middleware for use in routes
func (adh *AdminDashboardHandler) JWTMiddleware() gin.HandlerFunc {
return adh.jwt.GinMiddleware()
}

View File

@@ -7,23 +7,25 @@ import (
) )
type ApiEndpointHandler struct { type ApiEndpointHandler struct {
PostService services.PostService postService *services.PostService
imgKitHelper *services.ImgKitHelper
} }
var ApiEndpointHandlerInstance *ApiEndpointHandler func NewApiEndpointHandler(
postService *services.PostService,
func InitApiEndpointHandler() { imgKitHelper *services.ImgKitHelper,
ApiEndpointHandlerInstance = &ApiEndpointHandler{ ) *ApiEndpointHandler {
PostService: *services.PostServiceInstance, return &ApiEndpointHandler{
postService: postService,
imgKitHelper: imgKitHelper,
} }
} }
func (ps *ApiEndpointHandler) GetRandomPost(c *gin.Context) { func (aeh *ApiEndpointHandler) GetRandomPost(c *gin.Context) {
post := ps.PostService.GetRandomPost() post := aeh.postService.GetRandomPost()
for i := range post.Attachments { for i := range post.Attachments {
post.Attachments[i].RemoteUrl = services.GetRemoteUrl(post.Attachments[i].RemoteUrl) post.Attachments[i].RemoteUrl = aeh.imgKitHelper.GetRemoteUrl(post.Attachments[i].RemoteUrl)
post.Attachments[i].PreviewUrl = services.GetPreviewUrl(post.Attachments[i].RemoteUrl) post.Attachments[i].PreviewUrl = aeh.imgKitHelper.GetPreviewUrl(post.Attachments[i].RemoteUrl)
} }
c.JSON(200, post) c.JSON(200, post)
} }

View File

@@ -7,21 +7,24 @@ import (
) )
type EmbedCardHandler struct { type EmbedCardHandler struct {
PostService services.PostService postService *services.PostService
imgKitHelper *services.ImgKitHelper
} }
var EmbedCardHandlerInstance *EmbedCardHandler func NewEmbedCardHandler(
postService *services.PostService,
func InitEmbedCardHandler() { imgKitHelper *services.ImgKitHelper,
EmbedCardHandlerInstance = &EmbedCardHandler{ ) *EmbedCardHandler {
PostService: *services.PostServiceInstance, return &EmbedCardHandler{
postService: postService,
imgKitHelper: imgKitHelper,
} }
} }
func (ps *EmbedCardHandler) GetEmbedCard(c *gin.Context) { func (ech *EmbedCardHandler) GetEmbedCard(c *gin.Context) {
post := ps.PostService.GetRandomPost() post := ech.postService.GetRandomPost()
c.HTML(200, "home/embed.html", gin.H{ c.HTML(200, "home/embed.html", gin.H{
"postUrl": post.Url, "postUrl": post.Url,
"imageUrl": services.GetRemoteUrl(post.Attachments[0].RemoteUrl), "imageUrl": ech.imgKitHelper.GetRemoteUrl(post.Attachments[0].RemoteUrl),
}) })
} }

View File

@@ -10,21 +10,25 @@ import (
) )
type OauthLoginHandler struct { type OauthLoginHandler struct {
Jwt auth.JwtTokenGenerator jwt *auth.JwtTokenGenerator
OauthLoginHandler *auth.GiteaOAuth2Handler oauthHandler *auth.GiteaOAuth2Handler
cfg *config.Config
} }
var OauthLoginHandlerInstance *OauthLoginHandler func NewOauthLoginHandler(
jwt *auth.JwtTokenGenerator,
func InitOauthLoginHandler() { oauthHandler *auth.GiteaOAuth2Handler,
OauthLoginHandlerInstance = &OauthLoginHandler{ cfg *config.Config,
Jwt: *auth.JwtTokenGeneratorInstance, ) *OauthLoginHandler {
OauthLoginHandler: auth.GiteaOauth2HandlerInstance, return &OauthLoginHandler{
jwt: jwt,
oauthHandler: oauthHandler,
cfg: cfg,
} }
} }
func (olh *OauthLoginHandler) GoToGiteaLogin(c *gin.Context) { func (olh *OauthLoginHandler) GoToGiteaLogin(c *gin.Context) {
redirectURL, _ := olh.OauthLoginHandler.GetGiteaLoginURL(c.Request.URL.Scheme + c.Request.Host) redirectURL, _ := olh.oauthHandler.GetGiteaLoginURL(c.Request.URL.Scheme + c.Request.Host)
if redirectURL != "" { if redirectURL != "" {
c.Redirect(http.StatusFound, redirectURL) c.Redirect(http.StatusFound, redirectURL)
return return
@@ -42,27 +46,28 @@ func (olh *OauthLoginHandler) LoginWithGitea(c *gin.Context) {
return return
} }
userEmail, err := olh.OauthLoginHandler.GetGiteaUserEmailByCode(input.Code) userEmail, err := olh.oauthHandler.GetGiteaUserEmailByCode(input.Code)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
for _, email := range config.Config.GiteaOauthAllowedEmails { // Check if the user's email is in the allowed list
for _, email := range olh.cfg.GiteaOauthAllowedEmails {
if email == userEmail { if email == userEmail {
token, err := olh.Jwt.GenerateToken(map[string]interface{}{"role": "admin"}) token, err := olh.jwt.GenerateToken(map[string]interface{}{"role": "admin"})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "Login successful", "token": token}) c.JSON(http.StatusOK, gin.H{"message": "Login successful", "token": token})
} else {
c.JSON(401, gin.H{
"error": "oath login faied or yyour email does not have access",
})
return return
} }
} }
} // If we get here, the email is not in the allowed list
c.JSON(401, gin.H{
"error": "oauth login failed or your email does not have access",
})
}