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
# 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

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
import (
"context"
"time"
"CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/database"
"CatsOfMastodonBotGo/internal/domain"
"CatsOfMastodonBotGo/internal/server"
"CatsOfMastodonBotGo/internal/services"
"context"
"log/slog"
"strconv"
"time"
"CatsOfMastodonBotGo/internal/web/handlers"
"go.uber.org/fx"
"go.uber.org/zap"
)
func main() {
// Setup config
config.Init()
// Initialize database
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())
}
fx.New(
fx.Provide(
// Logger
NewLogger,
// 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/twitchyliquid64/golang-asm v0.15.1 // 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/crypto v0.40.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/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=

View File

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

View File

@@ -5,45 +5,39 @@ import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"go.uber.org/zap"
)
type GiteaOAuth2Handler struct {
ClientID string
ClientSecret string
InstanceUrl string
cfg *config.Config
logger *zap.Logger
}
var GiteaOauth2HandlerInstance *GiteaOAuth2Handler
func InitGiteaOauth2Token() {
GiteaOauth2HandlerInstance = &GiteaOAuth2Handler{
ClientID: config.Config.GiteaOauthClientID,
ClientSecret: config.Config.GiteaOauthClientSecret,
InstanceUrl: config.Config.GiteaOauthInstance,
}
func NewGiteaOauth2Token(cfg *config.Config) *GiteaOAuth2Handler {
return &GiteaOAuth2Handler{cfg: cfg}
}
func (g *GiteaOAuth2Handler) GetGiteaLoginURL(redirectHost string) (string, error) {
if g.InstanceUrl == "" {
if g.cfg.GiteaOauthInstance == "" {
return "", nil
}
if redirectHost == "" {
slog.Error("Redirect host not provided")
g.logger.Error("Redirect host not provided")
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
}
func (g *GiteaOAuth2Handler) GetGiteaUserEmailByCode(code string) (string, error) {
if g.InstanceUrl == "" {
slog.Error("Instance URL not provided")
if g.cfg.GiteaOauthInstance == "" {
g.logger.Error("Instance URL not provided")
return "", nil
}
// 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
}
userInfoUrl := g.InstanceUrl + "/login/oauth/userinfo"
slog.Info(userInfoUrl)
userInfoUrl := g.cfg.GiteaOauthInstance + "/login/oauth/userinfo"
g.logger.Info(userInfoUrl)
req, err := http.NewRequest("POST", userInfoUrl, nil)
if err != nil {
return "", err
@@ -85,12 +79,12 @@ func (g *GiteaOAuth2Handler) GetGiteaUserEmailByCode(code string) (string, error
func (g *GiteaOAuth2Handler) getGiteaAccessTokenByCode(code string) (string, error) {
form := url.Values{}
form.Add("client_id", g.ClientID)
form.Add("client_secret", g.ClientSecret)
form.Add("client_id", g.cfg.GiteaOauthClientID)
form.Add("client_secret", g.cfg.GiteaOauthClientSecret)
form.Add("code", 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 {
return "", err
}
@@ -112,4 +106,4 @@ func (g *GiteaOAuth2Handler) getGiteaAccessTokenByCode(code string) (string, err
}
return tokenResp.AccessToken, nil
}
}

View File

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

View File

@@ -11,13 +11,11 @@ import (
"gorm.io/gorm"
)
var Gorm *gorm.DB
func Connect() (*gorm.DB, error) {
func Connect(cfg *config.Config) (*gorm.DB, error) {
var db *gorm.DB
var err error = nil
if config.Config.DBEngine == "sqlite" {
if cfg.DBEngine == "sqlite" {
_, err = os.ReadDir("data")
if err != nil {
err = os.Mkdir("data", 0755)
@@ -31,11 +29,11 @@ func Connect() (*gorm.DB, error) {
}
} else {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.Config.DBUser,
config.Config.DBPassword,
config.Config.DBHost,
config.Config.DBPort,
config.Config.DBName)
cfg.DBUser,
cfg.DBPassword,
cfg.DBHost,
cfg.DBPort,
cfg.DBName)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
@@ -54,13 +52,4 @@ func Connect() (*gorm.DB, error) {
return nil, err
}
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
import (
"CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/web/handlers"
"context"
"net/http"
"CatsOfMastodonBotGo/internal/web/handlers"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/static"
"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.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowAllOrigins: true,
AllowMethods: []string{"POST", "GET", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
@@ -22,44 +38,37 @@ func SetupRouter() *gin.Engine {
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
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "home/index.html", nil)
})
// Embed card
r.GET("/embed", handlers.EmbedCardHandlerInstance.GetEmbedCard)
r.GET("/embed", params.EmbedCard.GetEmbedCard)
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)))
// I dont know a better way for path handling
r.Use(static.Serve("/admin/oauth/gitea/callback", static.LocalFile("internal/web/templates/admin", true)))
adminApi := admin.Group("/api")
adminApi.POST("/login", handlers.AdminDashboardHandlerInstance.Login)
adminApi.GET("/login/oauth/gitea", handlers.OauthLoginHandlerInstance.GoToGiteaLogin)
adminApi.POST("/login/oauth/gitea/final", handlers.OauthLoginHandlerInstance.LoginWithGitea)
adminApi.GET("/getmedia", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.GetMedia)
adminApi.POST("/approve", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.ApproveMedia)
adminApi.POST("/reject", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.RejectMedia)
adminApi.POST("/login", params.AdminDashboard.Login)
adminApi.GET("/login/oauth/gitea", params.OauthLogin.GoToGiteaLogin)
adminApi.POST("/login/oauth/gitea/final", params.OauthLogin.LoginWithGitea)
adminApi.GET("/getmedia", params.AdminDashboard.JWTMiddleware(), params.AdminDashboard.GetMedia)
adminApi.POST("/approve", params.AdminDashboard.JWTMiddleware(), params.AdminDashboard.ApproveMedia)
adminApi.POST("/reject", params.AdminDashboard.JWTMiddleware(), params.AdminDashboard.RejectMedia)
api := r.Group("/api")
api.GET("/post/random", params.ApiEndpoint.GetRandomPost)
api.GET("/post/random", handlers.ApiEndpointHandlerInstance.GetRandomPost)
return r
}
params.Lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
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"
type ImgKitHelper struct {
cfg *config.Config
}
var ImgKitHelperInstance *ImgKitHelper
func InitImgKitHelper() {
ImgKitHelperInstance = &ImgKitHelper{}
func NewImgKitHelper(cfg *config.Config) *ImgKitHelper {
return &ImgKitHelper{cfg: cfg}
}
func GetPreviewUrl(url string) string {
if config.Config.ImageKitId == "" {
func (ikh *ImgKitHelper) GetPreviewUrl(url string) string {
if ikh.cfg.ImageKitId == "" {
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 {
if config.Config.ImageKitId == "" {
func (ikh *ImgKitHelper) GetRemoteUrl(url string) string {
if ikh.cfg.ImageKitId == "" {
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 (
"CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/database"
"CatsOfMastodonBotGo/internal/domain"
"context"
"encoding/json"
@@ -16,42 +15,41 @@ import (
)
type PostService struct {
db *gorm.DB
db *gorm.DB
cfg *config.Config
}
var PostServiceInstance *PostService
// Constructor
func InitPostService() {
PostServiceInstance = &PostService{db: database.Gorm}
func NewPostService(db *gorm.DB, cfg *config.Config) *PostService {
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"
req, err := http.NewRequestWithContext(ctx, "GET", requestUrl, nil)
if err != nil {
return err, nil
return nil, err
}
resp, err := http.DefaultClient.Do(req)
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" {
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
err = json.NewDecoder(resp.Body).Decode(&posts)
if err != nil {
return err, nil
return nil, err
}
// defer: it basically means "do this later when the function returns"
defer resp.Body.Close()
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 {
@@ -66,7 +64,7 @@ func (ps *PostService) GetExistingAccountIds() []string {
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
for _, post := range posts {
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
}
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
for _, post := range posts {
if !arrayContains(existingAccountIds, post.Account.AccId) {
@@ -153,7 +151,7 @@ func (ps *PostService) RejectMedia(mediaId string) bool {
func (ps *PostService) GetMedia() domain.MediaAttachment {
var media domain.MediaAttachment
orderExpr := "RANDOM()" // sqlite
if config.Config.DBEngine != "sqlite" {
if ps.cfg.DBEngine != "sqlite" {
orderExpr = "RAND()" // mariadb/mysql
}
ps.db.Model(&domain.MediaAttachment{}).
@@ -171,4 +169,4 @@ func arrayContains(arr []string, str string) bool {
}
}
return false
}
}

View File

@@ -5,60 +5,65 @@ import (
"CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/web/dto"
"CatsOfMastodonBotGo/internal/services"
"CatsOfMastodonBotGo/internal/web/dto"
"github.com/gin-gonic/gin"
)
type AdminDashboardHandler struct {
PostService services.PostService
Jwt auth.JwtTokenGenerator
postService *services.PostService
jwt *auth.JwtTokenGenerator
cfg *config.Config
}
var AdminDashboardHandlerInstance *AdminDashboardHandler
func InitAdminDashboardHandler() {
AdminDashboardHandlerInstance = &AdminDashboardHandler{
PostService: *services.PostServiceInstance,
Jwt: *auth.JwtTokenGeneratorInstance,
func NewAdminDashboardHandler(
postService *services.PostService,
jwt *auth.JwtTokenGenerator,
cfg *config.Config,
) *AdminDashboardHandler {
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
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ps.PostService.ApproveMedia(input.MediaId) {
if adh.postService.ApproveMedia(input.MediaId) {
c.JSON(http.StatusOK, gin.H{"message": "Media approved successfully"})
} else {
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
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ps.PostService.RejectMedia(input.MediaId) {
if adh.postService.RejectMedia(input.MediaId) {
c.JSON(http.StatusOK, gin.H{"message": "Media rejected successfully"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject media"})
}
}
func (ps *AdminDashboardHandler) GetMedia(c *gin.Context) {
media := ps.PostService.GetMedia()
media.PreviewUrl = services.GetPreviewUrl(media.RemoteUrl)
media.RemoteUrl = services.GetPreviewUrl(media.RemoteUrl)
func (adh *AdminDashboardHandler) GetMedia(c *gin.Context) {
media := adh.postService.GetMedia()
// TODO: Fix this - we need to inject ImgKitHelper
// media.PreviewUrl = services.GetPreviewUrl(media.RemoteUrl)
// media.RemoteUrl = services.GetPreviewUrl(media.RemoteUrl)
c.JSON(http.StatusOK, media)
}
func (ps *AdminDashboardHandler) Login(c *gin.Context) {
func (adh *AdminDashboardHandler) Login(c *gin.Context) {
var input dto.LoginInput
@@ -68,8 +73,8 @@ func (ps *AdminDashboardHandler) Login(c *gin.Context) {
return
}
if input.Password == config.Config.AdminPassword { // Its more than enough for this project
token, err := ps.Jwt.GenerateToken(map[string]interface{}{"role": "admin"})
if input.Password == adh.cfg.AdminPassword { // Its more than enough for this project
token, err := adh.jwt.GenerateToken(map[string]interface{}{"role": "admin"})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
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 {
PostService services.PostService
postService *services.PostService
imgKitHelper *services.ImgKitHelper
}
var ApiEndpointHandlerInstance *ApiEndpointHandler
func InitApiEndpointHandler() {
ApiEndpointHandlerInstance = &ApiEndpointHandler{
PostService: *services.PostServiceInstance,
func NewApiEndpointHandler(
postService *services.PostService,
imgKitHelper *services.ImgKitHelper,
) *ApiEndpointHandler {
return &ApiEndpointHandler{
postService: postService,
imgKitHelper: imgKitHelper,
}
}
func (ps *ApiEndpointHandler) GetRandomPost(c *gin.Context) {
post := ps.PostService.GetRandomPost()
func (aeh *ApiEndpointHandler) GetRandomPost(c *gin.Context) {
post := aeh.postService.GetRandomPost()
for i := range post.Attachments {
post.Attachments[i].RemoteUrl = services.GetRemoteUrl(post.Attachments[i].RemoteUrl)
post.Attachments[i].PreviewUrl = services.GetPreviewUrl(post.Attachments[i].RemoteUrl)
post.Attachments[i].RemoteUrl = aeh.imgKitHelper.GetRemoteUrl(post.Attachments[i].RemoteUrl)
post.Attachments[i].PreviewUrl = aeh.imgKitHelper.GetPreviewUrl(post.Attachments[i].RemoteUrl)
}
c.JSON(200, post)
}
}

View File

@@ -7,21 +7,24 @@ import (
)
type EmbedCardHandler struct {
PostService services.PostService
postService *services.PostService
imgKitHelper *services.ImgKitHelper
}
var EmbedCardHandlerInstance *EmbedCardHandler
func InitEmbedCardHandler() {
EmbedCardHandlerInstance = &EmbedCardHandler{
PostService: *services.PostServiceInstance,
func NewEmbedCardHandler(
postService *services.PostService,
imgKitHelper *services.ImgKitHelper,
) *EmbedCardHandler {
return &EmbedCardHandler{
postService: postService,
imgKitHelper: imgKitHelper,
}
}
func (ps *EmbedCardHandler) GetEmbedCard(c *gin.Context) {
post := ps.PostService.GetRandomPost()
func (ech *EmbedCardHandler) GetEmbedCard(c *gin.Context) {
post := ech.postService.GetRandomPost()
c.HTML(200, "home/embed.html", gin.H{
"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 {
Jwt auth.JwtTokenGenerator
OauthLoginHandler *auth.GiteaOAuth2Handler
jwt *auth.JwtTokenGenerator
oauthHandler *auth.GiteaOAuth2Handler
cfg *config.Config
}
var OauthLoginHandlerInstance *OauthLoginHandler
func InitOauthLoginHandler() {
OauthLoginHandlerInstance = &OauthLoginHandler{
Jwt: *auth.JwtTokenGeneratorInstance,
OauthLoginHandler: auth.GiteaOauth2HandlerInstance,
func NewOauthLoginHandler(
jwt *auth.JwtTokenGenerator,
oauthHandler *auth.GiteaOAuth2Handler,
cfg *config.Config,
) *OauthLoginHandler {
return &OauthLoginHandler{
jwt: jwt,
oauthHandler: oauthHandler,
cfg: cfg,
}
}
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 != "" {
c.Redirect(http.StatusFound, redirectURL)
return
@@ -42,27 +46,28 @@ func (olh *OauthLoginHandler) LoginWithGitea(c *gin.Context) {
return
}
userEmail, err := olh.OauthLoginHandler.GetGiteaUserEmailByCode(input.Code)
userEmail, err := olh.oauthHandler.GetGiteaUserEmailByCode(input.Code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
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 {
token, err := olh.Jwt.GenerateToken(map[string]interface{}{"role": "admin"})
token, err := olh.jwt.GenerateToken(map[string]interface{}{"role": "admin"})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
return
}
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
}
}
}
// 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",
})
}