Compare commits

...

7 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
d4044b0eaf Added Gitea oauth to the admin dashboard and finished the oauth handling 2025-09-15 22:24:12 +03:30
a84156f0a0 Minor fixes 2025-09-15 20:22:37 +03:30
001e3b66cb Added oauth for gitea 2025-09-15 19:10:49 +03:30
22 changed files with 702 additions and 378 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...)
}

11
go.mod
View File

@@ -29,19 +29,24 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.29 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
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
golang.org/x/arch v0.19.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
golang.org/x/sys v0.34.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

65
go.sum
View File

@@ -1,12 +1,8 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
@@ -18,16 +14,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -36,23 +28,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
@@ -63,21 +48,17 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -87,10 +68,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -103,33 +86,27 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
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=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
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=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -140,12 +117,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

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()
}
}
}

109
internal/auth/oauth2.go Normal file
View File

@@ -0,0 +1,109 @@
package auth
import (
"CatsOfMastodonBotGo/internal/config"
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"go.uber.org/zap"
)
type GiteaOAuth2Handler struct {
cfg *config.Config
logger *zap.Logger
}
func NewGiteaOauth2Token(cfg *config.Config) *GiteaOAuth2Handler {
return &GiteaOAuth2Handler{cfg: cfg}
}
func (g *GiteaOAuth2Handler) GetGiteaLoginURL(redirectHost string) (string, error) {
if g.cfg.GiteaOauthInstance == "" {
return "", nil
}
if redirectHost == "" {
g.logger.Error("Redirect host not provided")
return "", nil
}
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.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
accessToken, err := g.getGiteaAccessTokenByCode(code)
if err != nil {
return "", err
}
userInfoUrl := g.cfg.GiteaOauthInstance + "/login/oauth/userinfo"
g.logger.Info(userInfoUrl)
req, err := http.NewRequest("POST", userInfoUrl, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
return userInfo.Email, nil
}
func (g *GiteaOAuth2Handler) getGiteaAccessTokenByCode(code string) (string, error) {
form := url.Values{}
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.cfg.GiteaOauthInstance+"/login/oauth/access_token", bytes.NewBufferString(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", err
}
return tokenResp.AccessToken, nil
}

View File

@@ -1,13 +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
@@ -24,14 +25,17 @@ type config struct {
DBName string
ImageKitId 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
@@ -47,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"
}
@@ -58,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"
}
@@ -74,8 +78,18 @@ func Load() *config {
dbPassword := os.Getenv("CAOM_DB_PASSWORD")
dbName := os.Getenv("CAOM_DB_NAME")
giteaOauthInstance := os.Getenv("CAOM_GITEA_OAUTH_INSTANCE")
giteaOauthClientID := os.Getenv("CAOM_GITEA_OAUTH_CLIENT_ID")
giteaOauthClientSecret := os.Getenv("CAOM_GITEA_OAUTH_CLIENT_SECRET")
giteaOauthAllowedEmails := os.Getenv("CAOM_GITEA_OAUTH_ALLOWED_EMAILS")
var giteaOauthAllowedEmailsParsed []string
if giteaOauthAllowedEmails != "" {
giteaOauthAllowedEmailsParsed = strings.Split(giteaOauthAllowedEmails, ",")
}
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 = ""
@@ -86,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,
@@ -106,11 +120,10 @@ func Load() *config {
DBName: dbName,
ImageKitId: imageKitId,
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,38 +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'
handlers.InitAdminDashboardHandler()
handlers.InitApiEndpointHandler()
handlers.InitEmbedCardHandler()
// 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)))
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("/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

@@ -11,3 +11,7 @@ type LoginInput struct {
type RejectMediaInput struct {
MediaId string `json:"mediaId" binding:"required"`
}
type GiteaLoginInput struct {
Code string `json:"code" binding:"required"`
}

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

@@ -0,0 +1,73 @@
package handlers
import (
"CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/config"
"CatsOfMastodonBotGo/internal/web/dto"
"net/http"
"github.com/gin-gonic/gin"
)
type OauthLoginHandler struct {
jwt *auth.JwtTokenGenerator
oauthHandler *auth.GiteaOAuth2Handler
cfg *config.Config
}
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.oauthHandler.GetGiteaLoginURL(c.Request.URL.Scheme + c.Request.Host)
if redirectURL != "" {
c.Redirect(http.StatusFound, redirectURL)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get gitea login url"})
}
func (olh *OauthLoginHandler) LoginWithGitea(c *gin.Context) {
var input dto.GiteaLoginInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userEmail, err := olh.oauthHandler.GetGiteaUserEmailByCode(input.Code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 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"})
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})
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",
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<script type="module" crossorigin src="/admin/assets/index-JxecVd-K.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DShmOgsI.css">
<script type="module" crossorigin src="/admin/assets/index-D2PXWyfl.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BvVLAYUm.css">
</head>
<body>